The data builder is one of the three parts of Apex Forge Platform, the Apex I keep in a public repository at github.com/WTWatson/apex-forge-platform on GitHub. It is one class, ForgeDataBuilder, with three inner types: Builder, which holds the fluent API; ConfigException, which it throws for a bad call; and ValueProvider, the interface that gives each record in a list its own value. Its header says what it is for (ForgeDataBuilder.cls, lines 4 to 7): a generic fluent test data builder that works with any SObjectType, with no per-object subclass. It auto-fills required fields from Schema describes, supports count(n) for bulk construction and accepts a ValueProvider for per-record variation. This page documents what the code does at commit f9bf157, with the class and line for each claim. The class header carries Copyright (C) 2026 William Watson (lines 18 to 19); its test class, ForgeDataBuilderTest, has none. How to get the code into a project is on the overview page.
How do I build a record?
Start with ForgeDataBuilder.of(type), set fields with with(), and finish with build() or persist(). of() returns a new Builder for the SObjectType you pass (line 27); its constructor throws when the type is null (lines 40 to 44). with(field, value) stores the value against the field and returns the builder, so calls chain (lines 46 to 54). It refuses a null field (line 48) and a field that is not on the object (lines 49 to 51). That second check is fieldBelongsTo(type, field), a public static method that asks whether the field is among the object’s describe fields (lines 29 to 32). The values live in a map keyed by field (line 36), so a second with() for the same field replaces the first; laterWithOverwritesEarlier proves it (ForgeDataBuilderTest.cls, lines 258 to 268).
build() returns one record without DML (lines 84 to 90) and persist() inserts it first (lines 112 to 120). Both refuse to run when count() has set more than one record; buildList() and persistList() are the list forms (lines 92 to 110 and 122 to 127). Every record starts as sObjType.newSObject() (line 98), which the Apex Reference Guide’s SObjectType class page describes as constructing a new sObject of this type. The builder then puts each stored value on the record (lines 99 to 105), applies auto-fill if it is on (line 106) and adds the record to the list (line 107). The return type is SObject or List<SObject>, so you cast, as the tests do.
persist() and persistList() return the same records they built after insert (lines 117 to 119 and 124 to 126), and persistInsertsRecord asserts the returned record carries an Id (ForgeDataBuilderTest.cls, line 132). Neither method wraps the insert in a try and catch, so a DML error reaches your test as it is.
What does auto-fill put in a record?
A value for every field the code decides is required, judged by the field’s describe. Before the loop over records, buildList() collects a describe for every field on the object (lines 95 to 96 and 129 to 137). Then, for each record, applyAutoFill() walks those describes (lines 139 to 153). It skips a field on any of five conditions.
- You set it with
with(), including with an explicit null (line 143). isCreateable()is false (line 145).isNillable()is true (line 146).isDefaultedOnCreate()is true (line 147).- Its type is
REFERENCE(lines 148 to 149).
The Apex Reference Guide’s DescribeFieldResult class page gives the three describe methods their meaning. isCreateable() is true if the field can be created by the current user, so what auto-fill touches depends on who runs the test. isNillable() is true when the field can have empty content, so the code treats a field that cannot be empty as required. isDefaultedOnCreate() is true for a field the platform assigns a value to itself when the record is created even if none is passed; the page’s examples are Probability on Opportunity and Owner on most objects. A field that passes all five checks goes to defaultFor() (line 150), and its value is put on the record only when it is not null (line 151).
defaultFor() chooses by the DisplayType value that getType() returned, and its branches run in this order (lines 155 to 179).
STRINGandTEXTAREA: the textTestfor a single record, or the field’s API name, a space and the one-based index for a list, soLastName 1,LastName 2and so on (lines 158 to 159). WhengetLength()is above zero and the text is longer, it is cut to that length (lines 160 to 161).EMAIL:ForgeDataSeeder.email()(line 163).PHONE:ForgeDataSeeder.phone()(line 164). Both draw random values; the seeder’s page follows.URL:https://example.com(line 165).INTEGERandLONG: 0 (line 166).DOUBLE,CURRENCYandPERCENT: 0 (lines 167 to 168).DATE:Date.today()(line 169).DATETIME:Datetime.now()(line 170).TIME: midnight,Time.newInstance(0, 0, 0, 0)(line 171).BOOLEAN: false (line 172).PICKLISTandCOMBOBOX: the value of the first entry ingetPicklistValues()whoseisActive()is true (lines 173 to 177), per the PicklistEntry class page. A picklist with no active entry falls through.- Anything else returns null (line 178), and the field is left empty. The DisplayType page lists the values;
MULTIPICKLIST,ENCRYPTEDSTRINGandBASE64are among those the code does not name, andautoFillLeavesUnsupportedDisplayTypesNullchecks theBASE64case throughAttachment.Body(ForgeDataBuilderTest.cls, lines 248 to 256).
A required lookup is the gap to know about. Line 149 skips every REFERENCE field, whether or not it allows null, so a lookup that must have a value is left empty unless you set it with with() or parent(). build() hands you the record that way, and persist() runs insert on it (line 118) with no catch, so whatever the platform says about the missing value comes back to your test as the DML exception.
autoFill(false) turns the whole thing off (line 82): buildList() then collects no describes (line 96) and skips the auto-fill call (line 106). autoFillCanBeDisabled shows a Contact built that way with LastName null (ForgeDataBuilderTest.cls, lines 160 to 169).
How do I build many records at once?
count(n) sets how many records buildList() and persistList() make (lines 75 to 80); it refuses null and anything below 1 (line 77). The loop runs from index 0 to n minus 1 (line 97). A plain value set with with() is put on every record as it is. A value that implements ValueProvider is asked for the record’s value instead: the interface is one method, Object get(Integer index) (line 25), and the loop calls get(i) with the zero-based index before the put (lines 101 to 103). The auto-filled strings use the same index, one-based (line 159).
ForgeDataSeeder ships four providers for this: sequence(prefix), firstNames(), lastNames() and emails() (ForgeDataSeeder.cls, lines 175 to 193). sequence('Acct') yields Acct 1, Acct 2 and so on (lines 201 to 205), which countWithProviderAssignsPerIndexValues asserts for three accounts (ForgeDataBuilderTest.cls, lines 47 to 58). firstNames() and lastNames() cycle fixed lists by index modulo the list size (lines 207 to 213), so values repeat once the count passes the length of the list, and neither class promises a unique name per record. sequence() is distinct for every index by construction. The seeder’s own page follows this one.
A provider is any class with that one method, so a test can write its own, as the worked example does. The builder does not check what a provider returns; the value goes to put() as it is (line 104).
How do I link a child to a parent?
parent(fkField, savedRecord) sets a lookup to a record that is already in the database (lines 56 to 73). It checks four things in order, and each failure is a ConfigException.
- The field is not null (line 58).
- The field’s
getType()isREFERENCE, or the message says what type it got instead (lines 59 to 63). - The parent is not null and its
Idis not null, or the message says the parent must be saved before linking (lines 64 to 66). - The parent’s
SObjectTypeis in the field’sgetReferenceTo()list, or the message says the type is not a valid reference for the field (lines 67 to 71). The DescribeFieldResult page describesgetReferenceTo()as returning the parent objects of the field.
It then calls with(fkField, parentRecord.Id) (line 72), so the Id is stored like any other value, and a later with() on the same field would replace it. The method only reads the parent’s Id and type; it never inserts the parent for you. parentWiresLookup persists an Account first and then builds a Contact with parent(Contact.AccountId, account) (ForgeDataBuilderTest.cls, lines 60 to 72).
When does it throw ConfigException?
ConfigException is declared on line 23 as public class ConfigException extends Exception {}, which is the shape the Apex Developer Guide’s custom exceptions page gives: extend Exception, end the name in Exception, and inherit the constructors, one of which takes a single String message. Every throw in the class passes a message, and getMessage() returns it, so a test catches ForgeDataBuilder.ConfigException and asserts on the text, as the tests do from line 82 on. The throws, in file order:
Builderconstructor, null type:SObjectType required(line 42).with(), null field:field required(line 48); a field from another object:field <field> does not belong to <type>(line 50).parent(), null field:FK field required(line 58); not a reference:parent() requires a REFERENCE field, got <type>(lines 61 to 62); unsaved parent:parent must be saved before linking (Id is null)(line 65); wrong parent type:parent SObjectType <type> is not a valid reference for <field>(lines 69 to 70).count(), null or below 1:count must be >= 1(line 77).build()with a count above 1 (lines 86 to 88) andpersist()with the same (lines 114 to 116): each message says to use the list form,buildList()orpersistList().
All of them fire before any DML. autoFill() is the one setter with no check (line 82): autoFill(null) stores null, line 96 then uses it as the condition of a ternary, and nothing in the class handles that case.
What do the describes cost?
The source makes no claim about limits, so this count comes from reading the code. Each with() call runs one object describe through fieldBelongsTo() (line 31). Each parent() call runs one field describe (line 59) and then one more object describe through with() (line 72). Each buildList() with auto-fill on runs one object describe and one field describe for every field on the object (lines 133 to 134), once per call rather than once per record; build() and persist() each call buildList(), so they pay the same. The Apex Reference Guide’s Limits class page, in its note on the deprecated getChildRelationshipsDescribes(), says describe limits are no longer enforced in any API version, so no describe limit caps how many of these a transaction runs. The DML is one insert statement per persist() or persistList() call (lines 118 and 125), inserting as many records as count() set, and those count against the DML limits on the Execution Governors and Limits page.
A worked example
A test that persists one Account and three Contacts linked to it, with a sequence for the last names and a provider of the test’s own for the first names.
// AccountContactsTest.cls, written against API version 66.0
@IsTest
private class AccountContactsTest {
private class Names implements ForgeDataBuilder.ValueProvider {
private final List<String> names = new List<String>{ 'Ada', 'Grace', 'Edsger' };
public Object get(Integer index) { return names[index]; }
}
@IsTest
static void contactsAreLinkedToTheirAccount() {
Account parentAccount = (Account) ForgeDataBuilder.of(Account.SObjectType)
.with(Account.Name, 'Anvil Testing Ltd')
.persist();
List<SObject> contacts = ForgeDataBuilder.of(Contact.SObjectType)
.with(Contact.FirstName, new Names())
.with(Contact.LastName, ForgeDataSeeder.sequence('Contact'))
.parent(Contact.AccountId, parentAccount)
.count(3)
.persistList();
Assert.areEqual(3, [SELECT COUNT() FROM Contact WHERE AccountId = :parentAccount.Id]);
Assert.areEqual('Grace', ((Contact) contacts[1]).FirstName);
Assert.areEqual('Contact 2', ((Contact) contacts[1]).LastName);
Assert.areEqual(parentAccount.Id, ((Contact) contacts[2]).AccountId);
}
}
The Account is persisted first because parent() needs its Id (line 64). The Names provider returns the entry at the record’s index, so it is good for a count of three and would fail past it; sequence() has no such edge. Auto-fill fills the other required fields it can, judged by the describes in the org the test runs in; a required lookup or a type it does not handle would still need with(). The four fields are the ones the repository’s own tests use. This example was written against the source at f9bf157 and API version 66.0, and uses nothing the source does not provide. It has not been run in an org.
What do the tests prove?
ForgeDataBuilderTest.cls is one of the two classes in the ForgeData suite, with ForgeDataSeederTest (ForgeData.testSuite-meta.xml); the overview page shows the sf apex run test command, which repeats --suite-names for each suite. The twenty-two methods, in file order, with what each asserts:
buildSetsFieldsLiterally(lines 4 to 14): twowith()values come back on the built Account.buildAutoFillsRequiredStringField(lines 16 to 23): a Contact built with nothing set has a non-emptyLastName. It does not check the value isTest.buildRespectsExplicitOverAutoFill(lines 25 to 34): awith()value onLastNamesurvives auto-fill.countBuildsNRecords(lines 36 to 45):count(3)gives a list of three.countWithProviderAssignsPerIndexValues(lines 47 to 58):sequence('Acct')givesAcct 1,Acct 2,Acct 3.parentWiresLookup(lines 60 to 72): the Contact’sAccountIdequals the persisted Account’s Id.parentRejectsNonReferenceField(lines 74 to 85):parent(Contact.LastName, ...)throws withREFERENCEin the message.parentRejectsWrongSObjectType(lines 87 to 99): a Contact as the parent ofContact.AccountIdthrows withnot a valid reference.parentThrowsWhenParentUnsaved(lines 101 to 112): an Account with no Id throws withId is null.withThrowsWhenFieldNotOnSObject(lines 114 to 123):Contact.LastNameon an Account builder throws withdoes not belong to.persistInsertsRecord(lines 125 to 136): the returned Account has an Id and a query finds its name.persistListInsertsAll(lines 138 to 150): three records have Ids and a count query finds three.autoFillSkipsReferenceFields(lines 152 to 158): a built Contact hasAccountIdnull. IfAccountIdallows null, line 146 skips it before the type check on line 149 is reached, so the test cannot tell the two apart.autoFillCanBeDisabled(lines 160 to 169): withautoFill(false),LastNameis null.buildThrowsWhenCountGreaterThanOne(lines 171 to 180) andpersistThrowsWhenCountGreaterThanOne(lines 182 to 191): the messages namebuildListandpersistList.countRejectsZeroOrNegative(lines 193 to 202):count(0)throws with>= 1. Despite the name, no negative count and no null is tried.ofRejectsNull(lines 204 to 213):of(null)throws withrequired.autoFillSatisfiesOpportunityRequiredFields(lines 215 to 228): an Opportunity persisted with nothing set hasName,StageNameandCloseDatenon-null when queried back. It is the only test that queries auto-filled values back after an insert, and it proves the three values are there, not which branch ofdefaultFor()supplied them.countWithEmberCycleProviders(lines 230 to 246): two Contacts come fromfirstNames()andlastNames(); the first Contact has non-null first and last names, and the two first names differ. It proves consecutive values differ, not that they cycle or that last names differ.autoFillLeavesUnsupportedDisplayTypesNull(lines 248 to 256): a built Attachment hasBodynull. One type is covered, and the assertion message namesBASE64.laterWithOverwritesEarlier(lines 258 to 268): the secondwith()onNamewins.
No test asserts on a value from the EMAIL, PHONE, URL, number, DATETIME, TIME, BOOLEAN or COMBOBOX branches of defaultFor(), or on the string truncation on line 161. No test calls with() with a null field or a null value, parent() with a null field, count(null), autoFill(null) or fieldBelongsTo() directly. Those parts of this page are read from the code, not from a passing test.
What does the code not do?
- It does not know your object. There is no per-object subclass, no configuration and no registry; what counts as required is the describe at run time, and
isCreateable()is judged for the current user. - It does not fill lookups. Every
REFERENCEfield is skipped (line 149), required or not. A required lookup needswith()orparent(), andparent()needs a parent that is already saved (lines 64 to 66). - It does not fill every type. Fields outside the branches of
defaultFor()are left empty (line 178),MULTIPICKLIST,ENCRYPTEDSTRINGandBASE64among them, and so is a picklist with no active entry. - It promises no unique values. Auto-filled strings repeat
Testfor every single-record build, the seeded email and phone are random, and the seeder’s name providers cycle fixed lists. - It does not catch DML errors.
insertruns bare on lines 118 and 125. - It does not check a provider’s value. Whatever
get(i)returns goes toput()(line 104); a wrong type is the platform’s problem, not the builder’s. - It does not guard
autoFill(). Line 82 stores whatever Boolean you pass, null included. build()andpersist()refuse a count above one. You choose the list form yourself (lines 86 to 88 and 114 to 116).- Describes are per call, not cached. Each
with()describes the object again (line 31), and eachbuildList()with auto-fill on describes every field of the object again (lines 133 to 134).
References
- Apex Forge Platform, github.com/WTWatson/apex-forge-platform at commit f9bf157 (21 April 2026):
ForgeDataBuilder.clsandForgeDataBuilderTest.clswith their meta files underforce-app/main/default/classes,ForgeData.testSuite-meta.xmlunderforce-app/main/default/testSuites, andForgeDataSeeder.clsforemail(),phone()and the four providers. - Apex Reference Guide, DescribeFieldResult Class:
isCreateable(),isNillable(),isDefaultedOnCreate(),getType(),getLength(),getName(),getReferenceTo()andgetPicklistValues(). - Apex Reference Guide, DisplayType Enum: the field types
getType()returns. - Apex Reference Guide, SObjectType Class:
newSObject(). - Apex Reference Guide, PicklistEntry Class:
isActive()andgetValue(). - Apex Reference Guide, Limits Class: describe limits are no longer enforced in any API version (the usage note on the deprecated
getChildRelationshipsDescribes()). - Apex Developer Guide, Create Custom Exception Classes: extending
Exceptionand the constructor that takes a message. - Apex Developer Guide, Execution Governors and Limits: the DML limits
persist()andpersistList()count against.