Apex Forge Platform is a small set of Apex classes I keep in one public repository, github.com/WTWatson/apex-forge-platform on GitHub. It has three parts: a trigger handler with a dispatcher and a context object, a test data builder, and a seed-value helper for tests. This page describes the repository as it stands at commit f9bf157 and how to put the code into your own project.

What is in the repository?

Eight classes live under force-app/main/default/classes. Three make up the trigger handler: ForgeTriggerHandler, ForgeTriggerDispatcher and ForgeTriggerContext. ForgeDataBuilder is the data builder and ForgeDataSeeder is the seeder. The other three are tests: ForgeTriggerHandlerTest, ForgeDataBuilderTest and ForgeDataSeederTest.

Two Apex test suites sit under force-app/main/default/testSuites. The ForgeTriggerHandler suite runs ForgeTriggerHandlerTest. The ForgeData suite runs ForgeDataBuilderTest and ForgeDataSeederTest.

The project file sfdx-project.json sets sourceApiVersion to 66.0 and leaves namespace empty, and each class meta file declares API version 66.0. Salesforce documents both keys in the DX project configuration reference for sfdx-project.json. There is no package and no licence file. The code is public on GitHub, and each of the five framework classes has a header reading Copyright (C) 2026 William Watson; the three test classes have none.

Why does it exist?

The class headers say why, and the code matches them. The trigger handler exists so that handler code never reads Trigger.new or Trigger.oldMap itself. The ForgeTriggerContext header calls the class an immutable snapshot of the trigger context, passed to onTrigger() so handlers never reference Trigger.* directly (ForgeTriggerContext.cls, lines 4 to 5). The context carries operationType as a System.TriggerOperation value with the new and old records and maps (lines 12 to 16). The base class adds a per-handler bypass and a re-entry limit, both kept in static collections (ForgeTriggerHandler.cls, lines 11 to 14). Because Apex static variables live for the transaction, a bypass or a run count lasts until the transaction ends.

The data builder exists so a test can build any SObject without a builder subclass per object. Its header says it works with any SObjectType, auto-fills required fields from Schema describes and supports .count(n) for bulk construction (ForgeDataBuilder.cls, lines 4 to 7). The seeder exists to give those tests UK-shaped values: names, addresses, phone numbers, postcodes and companies, plus lorem text and range-bounded primitives (ForgeDataSeeder.cls, lines 4 to 7).

How do I add it to a project?

There is no package to install. Either deploy the repository’s force-app directory into an org, or copy the classes into your own package directory.

The first route is a clone and a deploy. sf project deploy start deploys metadata to an org from your local project, and --source-dir takes the path to the local source files, a file or a folder (the flag text is in deploy.metadata.md in the plugin source). Replace <alias> with the alias of an org you have already authorised.

# @salesforce/cli 2.152.14
git clone https://github.com/WTWatson/apex-forge-platform.git
cd apex-forge-platform
sf project deploy start --source-dir force-app --target-org <alias>

The second route is to copy the .cls and .cls-meta.xml files you want from force-app/main/default/classes into your own package directory, plus the two .testSuite-meta.xml files from force-app/main/default/testSuites if you want the suites. ForgeDataBuilder calls ForgeDataSeeder.email() and ForgeDataSeeder.phone() for two of its defaults (ForgeDataBuilder.cls, lines 163 to 164), so those two classes travel together. Keep the meta files with the classes; they carry the API version, 66.0, that the code was written against.

Either way, run the two suites afterwards. sf apex run test invokes Apex tests in an org; by default the tests run asynchronously and the command returns a test run ID, and --wait sets how many minutes to wait for the results (see runtest.md in the plugin source). For more than one suite, repeat --suite-names for each, as runtest.md shows.

# @salesforce/cli 2.152.14
sf apex run test --suite-names ForgeTriggerHandler --suite-names ForgeData --target-org <alias> --wait 10

These commands were checked against that version’s command reference and the plugin’s message sources rather than run in an org.

Trigger handler

A handler extends ForgeTriggerHandler, overrides onTrigger(ForgeTriggerContext ctx) and, to run more than once per transaction, overrides getMaxReEntries(), which defaults to 1 (ForgeTriggerHandler.cls, lines 14 and 63). handle() returns early when the handler is bypassed or has reached that limit; otherwise it counts the run, builds a context from the trigger context variables and calls onTrigger() (lines 46 to 61). ForgeTriggerDispatcher.run() calls handle() on one handler or an ordered list, and ForgeTriggerContext.hasChanged(record, field) is true on insert and otherwise compares the field with the old record (ForgeTriggerDispatcher.cls, lines 12 to 23; ForgeTriggerContext.cls, lines 60 to 68).

Data builder

ForgeDataBuilder.of(Contact.SObjectType).with(Contact.LastName, 'Smith').build() returns a Contact without DML; persist() inserts it, and count(n) with buildList() or persistList() builds n records, calling any ValueProvider value once per record index (ForgeDataBuilder.cls, lines 27 to 127). With auto-fill on, the builder fills each field you did not set that is createable, not nillable, not defaulted on create and not a reference (the DescribeFieldResult reference documents those describe methods; lines 139 to 179). Defaults follow the type: Test for a string, or the field name plus index in a list; zero for numbers; today for a date; false for a boolean; a seeded email or phone; the first active picklist value. parent(fkField, savedRecord) wires a lookup and throws ConfigException when the field is not a reference, the parent has no Id or its type is not in the field’s getReferenceTo() list (lines 56 to 73).

Data seeder

ForgeDataSeeder draws UK-shaped values from fixed lists: firstName(), lastName(), fullName(), company(), city() and streetAddress(), plus a phone() shaped 07NNN NNNNNN, a postalCode() with a space in it and an email() ending in @example.co.uk (ForgeDataSeeder.cls, lines 78 to 115). It also returns lorem word(), sentence(n) and paragraph(n), and bounded integerBetween, decimalBetween, dateBetween and datetimeBetween, which swap reversed bounds rather than fail (lines 117 to 165). sequence(prefix), firstNames(), lastNames() and emails() return ValueProvider implementations for ForgeDataBuilder.count(n), so each record in a list gets the value for its index (lines 175 to 193 and 201 to 219). sequence() and emails() are distinct for every index; firstNames() and lastNames() cycle their lists of 42 first names and 36 last names, so values repeat past that count.

More is coming, and each part above will get its own page here.

The repository is at github.com/WTWatson/apex-forge-platform; this page tracks commit f9bf157.

References