Skip to content

DML - Guide

Framework: KernDX Package Type: Managed Package

Target Audience:

  • Developers who write code that saves records and want every insert, update, and delete to be safe, bulk-ready, and easy to test
  • Architects who want one consistent way to handle transactions, security, and permission checks across the whole codebase
  • Business Analysts who need to understand what the data layer can do, how it protects data, and how it gets tested

What problem does this solve?

When you save records in Apex by hand, four things go wrong again and again. A multi-step save fails halfway and leaves orphaned records behind. A loop trips governor limits. Code lets a user write data they should never touch. And test setup grows into a wall of boilerplate.

This guide covers KernDX's data layer: one consistent way to save records so every insert, update, and delete is safe, bulk-ready, and easy to test. You build up the changes you want, then run them in a single all-or-nothing transaction that enforces the running user's permissions by default.

Developers read this to perform DML. Architects read it to standardise transaction and security patterns across the codebase. Use it whenever code writes data.


Mental model

Think of it as a removals firm for your records. You hand over the boxes (the records you want to save) and say which ones are parents and which are children. The firm works out the right order to load the van, carries everything in one trip, and if anything breaks on the way it brings the whole load back so you're never left half moved-in. You also decide up front whether the movers are allowed into rooms the current user isn't cleared to enter.


Use this when

  • Several records must succeed or fail together. A parent and its children, or a mix of inserts, updates, and deletes that should all commit or all roll back.
  • You're saving in bulk. One call handles large volumes without you writing loop-and-batch plumbing.
  • The running user's permissions must be respected. User-facing features, public sites, and communities where a user should only write what they're allowed to.
  • You want one consistent way to write data so every team member's code looks and behaves the same.

Don't use this when

  • You're only setting a field on Trigger.new in a before-trigger. Direct field assignment needs no DML at all; the framework adds nothing here.
  • It's a throwaway anonymous Apex script or a data-loader job where DML_Builder adds overhead without a real benefit. Raw Database.* is fine.

Quick Start

For everyday inserts, updates, and deletes, call DML_Builder. When several related records must save together as one unit, use DML_Transaction.

Step-by-step walkthrough: Fast Start - DML covers implementation, testing, and common pitfalls.

Simple bulk insert:

apex
List<Account> accounts = new List<Account>
{
	new Account(Name = 'Acme Corp'),
	new Account(Name = 'Global Industries')
};

DML_Builder.newTransaction().doInsert(accounts).execute();

Related objects in a single transaction:

apex
Account account = new Account(Name = 'Acme Corp');
Contact contact = new Contact(FirstName = 'Jane', LastName = 'Doe');

DML_Builder.newTransaction()
	.doInsert(account)
	.doInsert(contact, Contact.AccountId, account)
	.execute();

For deeper coverage, continue reading the sections below.


How to opt out

You are never locked in. The framework is opt-in, and for every common case the standard methods don't cover by default, there's a documented way to step outside on the same builder. When you need to skip the framework entirely, raw Database.* is always available.

You needUseSee
Partial-success DML (Database.insert(records, false) semantics)DML_Builder.allowPartial(). Failed rows surface as Database.SaveResult errors while the rest commit.Anti-Patterns, Capability Matrix
>10K rows in one logical transactionDML_Builder.async() streams operations through queueables/batches, and the chosen access mode propagates. The platform throws a catchable pre-flight exception that names .async() as the fix when a synchronous save would exceed UTIL_Limits.dmlRows().maximum().Bulk Utilities → Batch Processing
Per-transaction AccessLevel override.withUserMode() for explicit USER_MODE, .withSystemMode() for SYSTEM_MODE. Either overrides the flag-driven default.Access Mode (USER_MODE / SYSTEM_MODE)
Bypass sharing for one operation.bypassSharing() routes through the without sharing proxy for that transaction only.Bypass vs Enforce vs Inherited
Inspect platform SaveResult / UpsertResult errors directlyTransactionResult.getErrors() returns the underlying platform errors after .execute().Anti-Patterns
Skip the framework entirely for one edge caseDatabase.insert(records, false, AccessLevel.SYSTEM_MODE) works unmodified, because nothing intercepts raw platform DML.

Use the framework for the 95% common case, and use one of these options (or raw Database.*) for the 5% edge case. Both paths are fully supported.


Table of Contents

Expand
  1. What problem does this solve?
  2. Mental model
  3. Use this when
  4. Don't use this when
  5. Quick Start
  6. How to opt out
  7. Quick Navigation
  8. Why choose this over the built-in option?
  9. How does it work?
  10. Transactional DML Pattern (DML_Builder)
  11. Bulk DML Operations (DML_Builder)
  12. Sharing Enforcement
  13. Permission Checking (FLOW_CheckObjectPermissions)
  14. Test Data Factory
  15. Bulk Utilities (UTIL_BulkUpdates & UTIL_PurgeRecords)
  16. Testing
  17. Capability Matrix (for Analysts)
  18. Anti-Patterns
  19. Best Practices
  20. Related Documentation

Quick Navigation

I am a...I need to...Go to...
ArchitectDesign transaction patternsHow does it work?
ArchitectUnderstand sharing enforcementSharing Enforcement
DeveloperPerform DML operationsQuick Start
DeveloperBuild test dataTest Data Factory
DeveloperHandle complex transactionsTransactional DML Pattern
AnalystUnderstand permission checkingPermission Checking
AnalystKnow DML capabilitiesCapability Matrix

Why choose this over the built-in option?

Salesforce already gives you raw DML operations, the insert, update, and delete statements that write data directly. For a one-off field tweak in a before-trigger they're all you need, and this guide says so plainly. The trouble starts the moment a save spans several records, runs in bulk, must respect a user's permissions, or has to be tested: with raw statements you reassemble that plumbing by hand every time, and any inconsistency becomes a bug.

This framework gives you one consistent way to write records that handles all four cases. You build up the changes you want, then run them in a single all-or-nothing transaction that enforces the running user's permissions by default. Developers use it for every insert, update, and delete; architects use it to standardise transaction and security patterns across the codebase.

The framework is made of four parts that build on each other:

  1. DML_Transaction keeps a complex, multi-object save together: register everything, then commit it all or roll it all back (the Unit of Work pattern).
  2. DML_Builder is the everyday tool you call. It runs bulk inserts, updates, and deletes and lets you decide how sharing applies.
  3. Sharing Proxy decides whether a write respects the running user's record visibility or runs with elevated access.
  4. FLOW_CheckObjectPermissions confirms a user is allowed to create, read, update, or delete an object before you write to it.

A few helpers round out the toolkit:

  • TST_Builder builds test records (with required fields filled in for you) so test setup stays short.
  • UTIL_PurgeRecords deletes records in bulk for cleanup.
  • UTIL_BulkUpdates applies the same field change across many records at once.

DML Framework Scope: 6 DML classes providing transactional Unit of Work, bulk operations, sharing control, and partial success handling. The test data factory (TST_Builder, TST_Factory, TST_Mock) spans 10 utility classes used across 165 test classes and ~3,359 Apex test methods (see Metrics).

Responsibilities: The DML framework manages database writes (insert, update, delete, upsert, undelete) with transactional integrity, sharing control, and error handling. It does not query data (use selectors for that), and it does not contain business logic.

What you get:

  • Safe multi-object saves. Register related records and commit them together, so a half-finished save can't leave orphaned records behind.
  • Bulk-friendly by default. One call handles large volumes without you writing loop-and-batch plumbing.
  • Clear control over who can see what. Choose, per operation, whether a write respects the running user's sharing rules or runs with elevated access.
  • Permission checks before you write. Confirm a user is allowed to create, edit, or delete an object before the save happens.
  • Short, readable test data. Build test records without a wall of setup code.
  • One way to do it. Every team member writes data the same way, so the codebase stays predictable.

How does it work?

Architecture Diagram

text
+---------------------------------------------------------------------------+
|                       DML FRAMEWORK ARCHITECTURE                          |
+---------------------------------------------------------------------------+
|                                                                           |
|  Your Code / Trigger Action / Flow Invocable                              |
|        |                                                                  |
|        v                                                                  |
|  +-------------------------------------------------------------------+   |
|  |  Layer 4: FLOW_CheckObjectPermissions                             |   |
|  |  - Validates CRUD/FLS before DML                                  |   |
|  |  - Invocable for Flow integration                                 |   |
|  +-------------------------------+-----------------------------------+   |
|                                  |                                       |
|                                  v                                       |
|  +-------------------------------------------------------------------+   |
|  |  Layer 1: DML_Transaction (Unit of Work)                          |   |
|  |  - Registers insert, update, delete, upsert, undelete            |   |
|  |  - Manages parent-child relationship resolution                   |   |
|  |  - Single atomic commit or full rollback                          |   |
|  +-------------------------------+-----------------------------------+   |
|                                  |                                       |
|                                  v                                       |
|  +-------------------------------------------------------------------+   |
|  |  Layer 2: DML_Builder (Standardized DML)                          |   |
|  |  - Fluent API: newTransaction().doInsert().execute()              |   |
|  |  - Partial success (.allowPartial()) or all-or-nothing           |   |
|  |  - TransactionResult with error inspection                        |   |
|  +-------------------------------+-----------------------------------+   |
|                                  |                                       |
|                                  v                                       |
|  +-------------------------------------------------------------------+   |
|  |  Layer 3: Sharing Proxy                                           |   |
|  |  - BYPASS: without sharing context                                |   |
|  |  - ENFORCE: with sharing context                                  |   |
|  |  - INHERITED: caller's sharing context (default)                  |   |
|  +-------------------------------+-----------------------------------+   |
|                                  |                                       |
|                                  v                                       |
|                     +------------------------+                           |
|                     |    Database.*           |                           |
|                     |  (Salesforce DML)       |                           |
|                     +------------------------+                           |
|                                                                           |
+---------------------------------------------------------------------------+

Layer 1: DML_Transaction

What it does: Lets you register a set of related records, then save them all in a single transaction. Either every change commits, or if anything fails, all of them roll back. This is the Unit of Work pattern.

When to use it: When several records must succeed or fail together, especially when some of them are parents and children that need linking.

What it handles for you:

  • Registers records for insert, update, delete, upsert, and undelete
  • Works out the save order so parents go in before children
  • Fills in the child's lookup to its parent automatically
  • Commits everything in one transaction
  • Rolls the whole transaction back if any part fails

Example:

apex
// Creates an Account with related Contacts in a single transaction
DML_Builder.newTransaction()
	.doInsert(account)
	.doInsert(contact1, Contact.AccountId, account)
	.doInsert(contact2, Contact.AccountId, account)
	.execute(); // All objects inserted in correct order with relationships maintained

Layer 2: DML_Builder

What it does: Gives you one set of methods for every database write, with sharing control and error handling already built in, so you don't repeat that boilerplate everywhere.

When to use it: For every database insert, update, delete, upsert, and undelete. This is the layer you call day to day.

What it handles for you:

  • Bulk processing of many records in one call
  • Choosing how sharing is enforced
  • Deciding between all-or-nothing and partial commit
  • Catching and surfacing errors
  • Managing the platform Database.SaveResult/Database.DeleteResult objects for you

Example:

apex
// Bulk inserts accounts with sharing enforced
List<Account> accounts = new List<Account>
{
	new Account(Name = 'Account 1'),
	new Account(Name = 'Account 2'),
	new Account(Name = 'Account 3')
};

DML_Builder.TransactionResult result = DML_Builder.newTransaction()
	.doInsert(accounts)
	.execute();

Layer 3: Sharing Proxy

What it does: Decides how strictly a write respects security. A write can run with the current user's read/write permissions and record sharing enforced (USER_MODE), or skip all of those checks for system work (SYSTEM_MODE). It also controls how sharing rules apply.

When to use it: When you want explicit control over that security level, or over sharing, for a particular operation.

What it handles for you:

  • A safe default that's on automatically: USER_MODE enforces FLS (field-level security), CRUD (object create/read/update/delete permissions), and sharing, governed by the FeatureFlag.UserModeDml_Enabled record
  • .withUserMode() / .withSystemMode() to pick the security level explicitly
  • .bypassSharing() to route a write through a without sharing path (in SYSTEM_MODE)
  • Per-operation control through short chained method calls
  • AccessLevel.SYSTEM_MODE support

Example:

apex
// Default (USER_MODE): CRUD + FLS + sharing all enforced at the database level
DML_Builder.newTransaction().doInsert(accounts).execute();

// Bypass sharing for system operations (routes through `without sharing` proxy)
DML_Builder.newTransaction().doInsert(accounts).bypassSharing().execute();

// Framework-internal writes (e.g., log rows) — bypass CRUD/FLS
DML_Builder.newTransaction().doInsert(logEntries).withSystemMode().execute();

Layer 4: FLOW_CheckObjectPermissions

What it does: Checks whether a user is allowed to perform an operation before you attempt it, so you can stop a forbidden write with a clear message instead of an unhandled error. (See the Salesforce reference on enforcing permissions.)

When to use it: Before a write in user-facing features, or on public sites and communities where users may have limited access.

What it handles for you:

  • Object-level permission checks
  • Confirms create, read, update, and delete access
  • An invocable method so Flows can call it too
  • Checks based on the object's describe information

Example:

apex
// Checks if user can create Account records
FLOW_CheckObjectPermissions.DTO_Request request = new FLOW_CheckObjectPermissions.DTO_Request();
request.objectApiName = 'Account';

List<FLOW_CheckObjectPermissions.DTO_Response> results =
	FLOW_CheckObjectPermissions.checkPermissions(new List<FLOW_CheckObjectPermissions.DTO_Request>{request});

if(results[0].hasCreateAccess)
{
	Account account = new Account(Name = 'New Account');
	DML_Builder.newTransaction().doInsert(account).execute();
}
else
{
	LOG_Builder.build().error('User does not have permission to create Account records').emitAt('MyClass.myMethod');
}

Transactional DML Pattern (DML_Builder)

This pattern collects the record changes you want, then commits them in one transaction. It keeps your records linked correctly and saves parents before children. You call DML_Builder; it drives DML_Transaction behind the scenes.

Basic Usage

Call DML_Builder.newTransaction() to start a transaction, then chain on the operations you want with short method calls.

Example:

apex
// Basic transactional DML for creating related records
Account account = new Account(Name = 'Acme Corporation', Industry = 'Technology');
Contact contact = new Contact(FirstName = 'John', LastName = 'Doe', Email = 'john.doe@acme.com');
Opportunity opportunity = new Opportunity(Name = 'Big Deal', StageName = 'Prospecting', CloseDate = Date.today().addDays(30));

DML_Builder.newTransaction()
	.doInsert(account)
	.doInsert(contact, Contact.AccountId, account)
	.doInsert(opportunity, Opportunity.AccountId, account)
	.execute();

Managing Dependencies

You don't have to insert parents first and wire up the lookups yourself. The framework reads the relationship fields you pass to doInsert, inserts parents before children, and fills in each child's lookup to its parent for you.

Example:

apex
// DML_Builder resolves dependency order automatically from relationship fields
Account account1 = new Account(Name = 'Account 1');
Account account2 = new Account(Name = 'Account 2');

Contact contact1 = new Contact(LastName = 'Smith');
Contact contact2 = new Contact(LastName = 'Jones');

Opportunity opportunity = new Opportunity(Name = 'Deal', StageName = 'Closed Won', CloseDate = Date.today());

DML_Builder.newTransaction()
	.doInsert(account1)
	.doInsert(account2)
	.doInsert(contact1, Contact.AccountId, account1)
	.doInsert(contact2, Contact.AccountId, account2)
	.doInsert(opportunity, Opportunity.AccountId, account1)
	.execute();

Registering Relationships

When you want to link two new records that don't have Ids yet, use the three-argument form of doInsert. The framework inserts the parent first, then sets the child's lookup once the parent's Id exists.

Example:

apex
// Register relationships between new records
Account account = new Account(Name = 'Parent Account');

Contact contact1 = new Contact(FirstName = 'John', LastName = 'Doe');
Contact contact2 = new Contact(FirstName = 'Jane', LastName = 'Smith');

Opportunity opportunity = new Opportunity(
	Name = 'Big Opportunity',
	StageName = 'Prospecting',
	CloseDate = Date.today().addDays(60)
);

DML_Builder.newTransaction()
	.doInsert(account)
	.doInsert(contact1, Contact.AccountId, account)
	.doInsert(contact2, Contact.AccountId, account)
	.doInsert(opportunity, Opportunity.AccountId, account)
	.execute();

Upsert with External ID

An upsert inserts a record if it's new and updates it if it already exists. Pass an external ID field to doUpsert so the framework knows how to match an incoming record to an existing one.

Single Record:

apex
// Upsert using external ID field for matching
Account account = new Account(Name = 'Acme Corp', ExternalId__c = 'EXT-001');

DML_Builder.newTransaction()
	.doUpsert(account, Account.ExternalId__c)
	.execute(); // Inserts new record or updates existing record matching ExternalId__c

Multiple Records:

apex
// Bulk upsert using external ID field
List<Account> accounts = new List<Account>
{
	new Account(Name = 'Account 1', ExternalId__c = 'EXT-001'),
	new Account(Name = 'Account 2', ExternalId__c = 'EXT-002'),
	new Account(Name = 'Account 3', ExternalId__c = 'EXT-003')
};

DML_Builder.newTransaction()
	.doUpsert(accounts, Account.ExternalId__c)
	.execute(); // Upserts all records using ExternalId__c for matching

With Parent-Child Relationships:

apex
// Upsert parent with related child inserts
Account account = new Account(Name = 'Parent Account', ExternalId__c = 'EXT-PARENT');
Contact contact = new Contact(FirstName = 'John', LastName = 'Doe');

DML_Builder.newTransaction()
	.doUpsert(account, Account.ExternalId__c)
	.doInsert(contact, Contact.AccountId, account)
	.execute();

Important: All records of the same SObjectType within a single transaction must use the same external ID field. Attempting to register records with different external ID fields for the same SObjectType throws an IllegalStateException.

Example - Conflicting External ID Fields (Invalid):

apex
// This will throw an IllegalStateException
Account account1 = new Account(Name = 'Account 1', ExternalId__c = 'EXT-001');
Account account2 = new Account(Name = 'Account 2', AlternateExternalId__c = 'ALT-001');

DML_Builder.newTransaction()
	.doUpsert(account1, Account.ExternalId__c)
	.doUpsert(account2, Account.AlternateExternalId__c) // Throws IllegalStateException!
	.execute();

// Error: Cannot use different external ID fields for the same SObjectType in a single transaction

Mixed Operations

You can mix inserts, updates, and deletes in the same transaction. They all commit together, or all roll back if any one fails.

Example:

apex
// Mixed DML operations with error handling
try
{
	Account newAccount = new Account(Name = 'New Account');

	Account existingAccount = (Account)QRY_Builder.selectFrom(Account.SObjectType)
		.fields(new List<SObjectField>{Account.Id, Account.Name})
		.withLimit(1)
		.getFirst();
	existingAccount.Name = 'Updated Account';

	Account oldAccount = (Account)QRY_Builder.selectFrom(Account.SObjectType)
		.fields(new List<SObjectField>{Account.Id})
		.condition(Account.CreatedDate).lessThan(Date.today().addYears(-1))
		.withLimit(1)
		.getFirst();

	DML_Builder.newTransaction()
		.doInsert(newAccount)
		.doUpdate(existingAccount)
		.doDelete(oldAccount)
		.execute();
}
catch(Exception error)
{
	LOG_Builder.build().error(error).emitAt('MyClass.commitChanges');
	// All changes are rolled back automatically
}

Bulk DML Operations (DML_Builder)

Insert Operations

Insert one record or many in a single call, with sharing control applied.

Example:

apex
// Bulk insert with error handling
List<Account> accounts = new List<Account>();
for(Integer i = 0; i < 200; i++)
{
	accounts.add(new Account(Name = 'Bulk Account ' + i, Industry = 'Technology'));
}

// Insert with partial commit allowed
DML_Builder.newTransaction()
	.doInsert(accounts)
	.allowPartial()
	.execute();

Update Operations

Update existing records in bulk, with sharing control applied.

Example:

apex
// Bulk update with sharing enforced
List<Account> accounts = QRY_Builder.selectFrom(Account.SObjectType)
	.fields(new List<SObjectField>{Account.Id, Account.Name, Account.Industry})
	.condition(Account.Industry).equals('Technology')
	.withLimit(200)
	.toList();

for(Account account : accounts)
{
	account.Industry = 'Software';
	account.Description = 'Updated via bulk operation';
}

// Enforce sharing for update operation
DML_Builder.newTransaction()
	.doUpdate(accounts)
	.execute();

Delete Operations

Delete records in bulk, with sharing control applied.

Example:

apex
// Bulk delete with permission check
// Check delete permission first
FLOW_CheckObjectPermissions.DTO_Request request = new FLOW_CheckObjectPermissions.DTO_Request();
request.objectApiName = 'Contact';

List<FLOW_CheckObjectPermissions.DTO_Response> permissions =
	FLOW_CheckObjectPermissions.checkPermissions(new List<FLOW_CheckObjectPermissions.DTO_Request>{request});

if(permissions[0].hasDeleteAccess)
{
	List<Contact> contactsToDelete = QRY_Builder.selectFrom(Contact.SObjectType)
		.fields(new List<SObjectField>{Contact.Id})
		.condition(Contact.Email).contains('@test.com')
		.withLimit(100)
		.toList();

	DML_Builder.newTransaction()
		.doDelete(contactsToDelete)
		.allowPartial()
		.execute();
}
else
{
	LOG_Builder.build().error('User does not have delete permission').emitAt('MyClass.deleteContacts');
}

Upsert Operations

Upsert inserts new records and updates existing ones in the same call. By default it matches on the record Id; pass an external ID field to match on that instead.

Basic Upsert (by Record Id):

apex
// Upsert using record Id for matching (default behavior)
List<Account> accounts = new List<Account>
{
	new Account(Name = 'New Account'),  // No Id - will insert
	new Account(Id = existingId, Name = 'Updated Account')  // Has Id - will update
};

DML_Builder.newTransaction()
	.doUpsert(accounts)
	.execute();

Upsert with External ID Field:

apex
// Bulk upsert using external ID field for matching
List<Account> accounts = new List<Account>
{
	new Account(Name = 'Account 1', ExternalId__c = 'EXT-001'),
	new Account(Name = 'Account 2', ExternalId__c = 'EXT-002'),
	new Account(Name = 'Account 3', ExternalId__c = 'EXT-003')
};

// First upsert creates new records (no matching external IDs exist)
DML_Builder.newTransaction()
	.doUpsert(accounts, Account.ExternalId__c)
	.execute();

// Modify and upsert again - will update existing records
for(Account account : accounts)
{
	account.Industry = 'Technology';
}

DML_Builder.newTransaction()
	.doUpsert(accounts, Account.ExternalId__c)
	.execute();

Upsert with Sharing Enforcement:

apex
// External ID upsert with sharing rules enforced
DML_Builder.newTransaction()
	.doUpsert(accounts, Account.ExternalId__c)
	.execute();

Undelete Operations

Restore records that were deleted but are still in the recycle bin.

Example:

apex
// Undelete soft-deleted records
List<Account> deletedAccounts = QRY_Builder.selectFrom(Account.SObjectType)
	.fields(new List<SObjectField>{Account.Id, Account.Name})
	.condition(Account.IsDeleted).equals(true)
	.allRows()
	.toList();

if(!deletedAccounts.isEmpty())
{
	DML_Builder.newTransaction()
		.doUndelete(deletedAccounts)
		.execute();
}

Sharing Enforcement

Context-Driven Sharing

By default, a DML_Builder chain follows the sharing rules of the class that called it. You set that at the class level: with sharing for user-facing code, without sharing for system maintenance. When you need finer control for one write, the per-call methods (.bypassSharing(), .withUserMode(), .withSystemMode()) override the default, and each override is recorded so the decision can be audited later.

Example:

apex
// USER_MODE default + with-sharing class for community/portal contexts
public with sharing class CommunityAccountController
{
	public void createAccount(String accountName)
	{
		Account account = new Account(Name = accountName);
		DML_Builder.newTransaction()
			.doInsert(account)
			.execute();
	}

	public void updateAccount(Id accountId, String newName)
	{
		Account account = new Account(Id = accountId, Name = newName);
		DML_Builder.newTransaction()
			.doUpdate(account)
			.execute();
	}
}

// In a system-level batch or trigger
public without sharing class SystemBatchProcess implements Database.Batchable<SObject>
{
	public void execute(Database.BatchableContext context, List<SObject> scope)
	{
		// Bypass sharing for system operations
		DML_Builder.newTransaction()
			.doUpdate(scope)
			.bypassSharing()
			.execute();
	}
}

Operation-Level Sharing

By default DML_Builder runs in AccessLevel.USER_MODE, so object permissions, field permissions, and sharing are all enforced at the database level. To loosen that for a specific write: use .bypassSharing() to ignore record sharing (routing through a without sharing path), or .withSystemMode() to skip the permission checks entirely for framework-internal writes.

Access Mode (USER_MODE / SYSTEM_MODE)

Your DML calls default to AccessLevel.USER_MODE, which enforces the running user's field and object permissions on every insert, update, delete, upsert, and undelete. That default comes from the FeatureFlag.UserModeDml_Enabled custom metadata record, which ships with IsEnabledByDefault__c = true.

Force a specific mode:

apex
// Force USER_MODE (user-facing DML enforcing the running user's FLS)
DML_Builder.newTransaction()
	.withUserMode()
	.doInsert(record)
	.execute();

// Force SYSTEM_MODE (framework-internal writes — logs, orchestration records, etc.)
DML_Builder.newTransaction()
	.withSystemMode()
	.doInsert(logEntry)
	.execute();

Emergency kill-switch (a master off-switch you can flip in an incident without a deployment of code): set FeatureFlag.UserModeDml_Enabled.IsEnabledByDefault__c to false with a metadata deploy. It takes effect on the next transaction, and from then on every call that doesn't explicitly use .withUserMode() or .withSystemMode() reverts to AccessLevel.SYSTEM_MODE. See Security Guide, Safe by Default.

Example:

apex
// Operation-level sharing control
Account account = new Account(Name = 'Test Account');
Contact contact = new Contact(FirstName = 'Test', LastName = 'User');

DML_Builder.newTransaction()
	.doInsert(account)
	.doInsert(contact, Contact.AccountId, account)
	.execute();

Bypass vs Enforce vs Inherited

There are three ways sharing can apply to a write: bypass it, enforce it, or inherit whatever the calling class uses. The examples below show each.

Example:

apex
// Sharing mode examples
List<Account> accounts = new List<Account>{new Account(Name = 'Test')};

// BYPASS: Routes DML through the `without sharing` proxy (sharing rules ignored)
DML_Builder.newTransaction()
	.doInsert(accounts)
	.bypassSharing()
	.execute();
// User can insert records they normally could not access

// DEFAULT: USER_MODE (CRUD + FLS + sharing enforced at the database level)
// via the `UserModeDml_Enabled` feature flag shipped `true`
DML_Builder.newTransaction()
	.doInsert(accounts)
	.execute();
// Inserts fail if the running user lacks CRUD, FLS, or sharing access

Permission Checking (FLOW_CheckObjectPermissions)

Object-Level Permissions

Confirm a user is allowed to create, read, edit, or delete an object before you write to it, so you can return a clear message instead of letting the save fail. (Background: enforcing permissions.)

Example:

apex
// Check object permissions before DML
public static void safeCreateAccount(String accountName)
{
	// Check permissions first
	FLOW_CheckObjectPermissions.DTO_Request request = new FLOW_CheckObjectPermissions.DTO_Request();
	request.objectApiName = 'Account';

	List<FLOW_CheckObjectPermissions.DTO_Response> permissions =
		FLOW_CheckObjectPermissions.checkPermissions(
			new List<FLOW_CheckObjectPermissions.DTO_Request>{request}
		);

	FLOW_CheckObjectPermissions.DTO_Response accountPerms = permissions[0];

	if(accountPerms.hasCreateAccess)
	{
		Account account = new Account(Name = accountName);
		DML_Builder.newTransaction().doInsert(account).execute();
	}
	else
	{
		throw new SecurityException('User does not have permission to create Account records');
	}
}

Before DML Checks

Check the permissions once and reuse the result for several operations, rather than checking again for each write.

Example:

apex
// Check all CRUD permissions
public with sharing class AccountManager
{
	private FLOW_CheckObjectPermissions.DTO_Response accountPermissions;

	public AccountManager()
	{
		// Check permissions once during initialization
		FLOW_CheckObjectPermissions.DTO_Request request = new FLOW_CheckObjectPermissions.DTO_Request();
		request.objectApiName = 'Account';

		List<FLOW_CheckObjectPermissions.DTO_Response> results =
			FLOW_CheckObjectPermissions.checkPermissions(
				new List<FLOW_CheckObjectPermissions.DTO_Request>{request}
			);

		accountPermissions = results[0];
	}

	public void createAccount(Account account)
	{
		if(!accountPermissions.hasCreateAccess)
		{
			throw new SecurityException('No create permission');
		}
		DML_Builder.newTransaction().doInsert(account).execute();
	}

	public void updateAccount(Account account)
	{
		if(!accountPermissions.hasEditAccess)
		{
			throw new SecurityException('No edit permission');
		}
		DML_Builder.newTransaction().doUpdate(account).execute();
	}

	public void deleteAccount(Account account)
	{
		if(!accountPermissions.hasDeleteAccess)
		{
			throw new SecurityException('No delete permission');
		}
		DML_Builder.newTransaction().doDelete(account).execute();
	}
}

Field-Level Security

When you need to check access to one specific field rather than the whole object, read its field-level access from the field's describe information.

Example:

apex
// Check field-level security
public static Boolean canUpdateAccountRevenue()
{
	DescribeFieldResult fieldDescribe = Account.AnnualRevenue.getDescribe();
	return fieldDescribe.isUpdateable();
}

public static void safeUpdateRevenue(Id accountId, Decimal newRevenue)
{
	if(!canUpdateAccountRevenue())
	{
		throw new SecurityException('User cannot update AnnualRevenue field');
	}

	Account account = new Account(Id = accountId, AnnualRevenue = newRevenue);
	DML_Builder.newTransaction().doUpdate(account).execute();
}

Test Data Factory

TST_Builder

Writing test data by hand is tedious: you have to set every required field, link parents to children, and repeat it in every test. TST_Builder does that work for you. You name the object, override the fields you care about, and it fills in the rest with valid defaults. You configure it with short chained calls, then one call builds the record.

What it does for you:

  • Fills required fields automatically. You only set what your test cares about; valid defaults cover the rest.
  • Lets you override any field. Use String field names or type-safe SObjectField tokens.
  • Creates many records at once. Use withCount() and buildList() for bulk data.
  • Builds parent-child graphs. Children get linked to their parent automatically.
  • Sets record types by developer name.
  • Controls optional fields. Decide which optional fields get populated.
  • Can be extended. Customise how default values are generated when you need to.

Basic Usage

Build one record. Every required field is filled in for you:

apex
@IsTest
private static void testBasicAccountCreation()
{
	Test.startTest();

	// Create and insert account with auto-generated required fields
	Account account = (Account)TST_Builder.of(Account.SObjectType).build();

	Test.stopTest();

	Assert.isNotNull(account.Id, 'Account should be inserted');
	Assert.isNotNull(account.Name, 'Required field should be auto-populated');
}

Build a record in memory only, without saving it to the database:

apex
@IsTest
private static void testInMemoryAccount()
{
	Test.startTest();

	// Create account in memory only (no DML)
	Account account = (Account)TST_Builder.of(Account.SObjectType)
		.withoutInsertion()
		.build();

	Test.stopTest();

	Assert.isNull(account.Id, 'Account should not be inserted');
	Assert.isNotNull(account.Name, 'Required fields still populated');
}

Field Overrides

Set the fields you care about, either with String field names or with type-safe SObjectField tokens (tokens are preferred, since the compiler catches a typo):

Single Field Override:

apex
@IsTest
private static void testFieldOverrides()
{
	Test.startTest();

	// Using SObjectField tokens (recommended - compile-time safety)
	Account account = (Account)TST_Builder.of(Account.SObjectType)
		.withOverride(Account.Name, 'ACME Corp')
		.withOverride(Account.Industry, 'Technology')
		.withOverride(Account.AnnualRevenue, 1000000)
		.build();

	Test.stopTest();

	Assert.areEqual('ACME Corp', account.Name);
	Assert.areEqual('Technology', account.Industry);
	Assert.areEqual(1000000, account.AnnualRevenue);
}

Multiple Field Overrides:

apex
@IsTest
private static void testBulkOverrides()
{
	Test.startTest();

	// Using Map for multiple overrides
	Account account = (Account)TST_Builder.of(Account.SObjectType)
		.withOverrides(new Map<SObjectField, Object>
		{
			Account.Name => 'Test Account',
			Account.Phone => '555-1234',
			Account.Industry => 'Finance',
			Account.NumberOfEmployees => 500
		})
		.build();

	Test.stopTest();

	Assert.areEqual('Test Account', account.Name);
	Assert.areEqual('555-1234', account.Phone);
}

String-Based Overrides (when SObjectField tokens aren't available):

apex
Contact contact = (Contact)TST_Builder.of(Contact.SObjectType)
	.withOverride('FirstName', 'John')
	.withOverride('LastName', 'Doe')
	.withOverrides(new Map<String, Object>{'Email' => 'john@example.com'})
	.build();

Bulk Record Creation

To create many records at once, set how many with withCount() and build them with buildList():

apex
@IsTest
private static void testBulkCreation()
{
	Test.startTest();

	// Create and insert 200 accounts in bulk
	List<Account> accounts = (List<Account>)TST_Builder.of(Account.SObjectType)
		.withCount(200)
		.withOverride(Account.Industry, 'Technology')
		.buildList();

	Test.stopTest();

	Assert.areEqual(200, accounts.size());
	Assert.isNotNull(accounts[0].Id, 'Records should be inserted');
	Assert.areEqual('Technology', accounts[0].Industry);
}

Pattern for Custom Bulk Data:

apex
@IsTest
private static void testCustomBulkData()
{
	Integer numberOfAccounts = 100;
	List<Account> accounts = new List<Account>();

	Test.startTest();

	// Create records in memory with custom values per record
	for(Integer i = 0; i < numberOfAccounts; i++)
	{
		Account account = (Account)TST_Builder.of(Account.SObjectType)
			.withOverrides(new Map<SObjectField, Object>
			{
				Account.Name => 'Account ' + i,
				Account.AnnualRevenue => 100000 * i
			})
			.withoutInsertion()
			.build();
		accounts.add(account);
	}

	// Insert all at once using DML_Builder
	DML_Builder.newTransaction().doInsert(accounts).execute();

	Test.stopTest();

	Assert.areEqual(numberOfAccounts, accounts.size());
}

Record Type Support

Assign a record type by its developer name:

apex
@IsTest
private static void testRecordType()
{
	Test.startTest();

	Account account = (Account)TST_Builder.of(Account.SObjectType)
		.withRecordType('Enterprise_Account')
		.withOverride(Account.Name, 'Enterprise Corp')
		.build();

	Test.stopTest();

	Assert.isNotNull(account.RecordTypeId);
	// Verify it's the correct record type
	RecordType recordType = (RecordType)QRY_Builder.selectFrom(RecordType.SObjectType)
		.fields(new List<SObjectField>{RecordType.DeveloperName})
		.condition(RecordType.Id).equals(account.RecordTypeId)
		.getFirst();
	Assert.areEqual('Enterprise_Account', recordType.DeveloperName);
}

Error Handling:

apex
try
{
	TST_Builder.of(Account.SObjectType)
		.withRecordType('NonExistent_RecordType')
		.build();
	Assert.fail('Should throw IllegalArgumentException');
}
catch(Exception error)
{
	Assert.isInstanceOfType(error, IllegalArgumentException.class, 'Incorrect Exception Type');
}

Parent-Child Relationships

Build a parent and its children together; the framework links each child to its parent for you. There are several ways to do this, depending on how much you need to customise the children. The patterns below go from simplest to most flexible.

Pattern 1: Simple Child Creation (No Field Overrides)

apex
@IsTest
private static void testSimpleChildren()
{
	Test.startTest();

	// Create account with 3 contacts (all fields auto-defaulted)
	Account account = (Account)TST_Builder.of(Account.SObjectType)
		.withOverride(Account.Name, 'Parent Account')
		.withChildren(Contact.SObjectType, 3)
		.build();

	Test.stopTest();

	Assert.isNotNull(account.Id);
	List<Contact> contacts = QRY_Builder.selectFrom(Contact.SObjectType)
		.fields(new List<SObjectField>{Contact.Id, Contact.AccountId})
		.condition(Contact.AccountId).equals(account.Id)
		.toList();
	Assert.areEqual(3, contacts.size(), 'Should have 3 child contacts');
	Assert.areEqual(account.Id, contacts[0].AccountId, 'Foreign key should be set');
}

Pattern 2: Children with Field Overrides

apex
@IsTest
private static void testChildrenWithOverrides()
{
	Test.startTest();

	// Create account with 2 contacts with specific field values
	Account account = (Account)TST_Builder.of(Account.SObjectType)
		.withOverride(Account.Name, 'ACME Corp')
		.withChildren(Contact.SObjectType, 2, new Map<SObjectField, Object>
		{
			Contact.FirstName => 'John',
			Contact.LastName => 'Smith',
			Contact.Email => 'john.smith@acme.com'
		})
		.build();

	Test.stopTest();

	List<Contact> contacts = QRY_Builder.selectFrom(Contact.SObjectType)
		.fields(new List<SObjectField>{Contact.FirstName, Contact.LastName, Contact.Email})
		.condition(Contact.AccountId).equals(account.Id)
		.toList();
	Assert.areEqual(2, contacts.size());
	Assert.areEqual('John', contacts[0].FirstName);
	Assert.areEqual('Smith', contacts[0].LastName);
}

Pattern 3: Children Using Builder (Complex Configuration)

apex
@IsTest
private static void testChildrenWithBuilder()
{
	Test.startTest();

	// Create account with opportunities using nested builder for complex config
	Account account = (Account)TST_Builder.of(Account.SObjectType)
		.withOverride(Account.Name, 'Sales Account')
		.withChildren(
			TST_Builder.of(Opportunity.SObjectType)
				.withCount(5)
				.withOverride(Opportunity.StageName, 'Prospecting')
				.withOverride(Opportunity.CloseDate, Date.today().addDays(30))
				.withDefaultedField(Opportunity.Description) // Force optional field population
		)
		.build();

	Test.stopTest();

	List<Opportunity> opportunities = QRY_Builder.selectFrom(Opportunity.SObjectType)
		.fields(new List<SObjectField>{Opportunity.Id, Opportunity.StageName, Opportunity.AccountId})
		.condition(Opportunity.AccountId).equals(account.Id)
		.toList();
	Assert.areEqual(5, opportunities.size());
	Assert.areEqual('Prospecting', opportunities[0].StageName);
}

Pattern 4: Multiple Child Types

apex
@IsTest
private static void testMultipleChildTypes()
{
	Test.startTest();

	// Create account with both contacts and opportunities
	Account account = (Account)TST_Builder.of(Account.SObjectType)
		.withOverride(Account.Name, 'Multi-Child Account')
		.withChildren(Contact.SObjectType, 3, new Map<SObjectField, Object>
		{
			Contact.LastName => 'Contact'
		})
		.withChildren(Opportunity.SObjectType, 2, new Map<SObjectField, Object>
		{
			Opportunity.StageName => 'Closed Won',
			Opportunity.CloseDate => Date.today()
		})
		.build();

	Test.stopTest();

	List<Contact> contacts = QRY_Builder.selectFrom(Contact.SObjectType)
		.fields(new List<SObjectField>{Contact.Id})
		.condition(Contact.AccountId).equals(account.Id)
		.toList();
	List<Opportunity> opportunities = QRY_Builder.selectFrom(Opportunity.SObjectType)
		.fields(new List<SObjectField>{Opportunity.Id})
		.condition(Opportunity.AccountId).equals(account.Id)
		.toList();

	Assert.areEqual(3, contacts.size());
	Assert.areEqual(2, opportunities.size());
}

Pattern 5: Explicit Relationship Name (Edge Cases)

When an object has more than one lookup to the same parent type, name the relationship explicitly so the framework knows which one you mean:

apex
@IsTest
private static void testExplicitRelationshipName()
{
	Test.startTest();

	// Custom object with two lookups to Contact: PrimaryContact__c and SecondaryContact__c
	// Must specify explicit relationship name when there are multiple relationships
	Foobar__c foobar = (Foobar__c)TST_Builder.of(Foobar__c.SObjectType)
		.withOverride(Foobar__c.Name, 'Test')
		.withChildren('PrimaryContacts__r',
			TST_Builder.of(Contact.SObjectType).withCount(2)
		)
		.withChildren('SecondaryContacts__r',
			TST_Builder.of(Contact.SObjectType).withCount(1)
		)
		.build();

	Test.stopTest();
}

In-Memory Parent-Child Graphs (No DML)

apex
@IsTest
private static void testInMemoryGraph()
{
	Test.startTest();

	// Create entire graph in memory without database insertion
	Account account = (Account)TST_Builder.of(Account.SObjectType)
		.withOverride(Account.Name, 'Test Account')
		.withChildren(Contact.SObjectType, 2, new Map<SObjectField, Object>
		{
			Contact.LastName => 'Smith'
		})
		.withoutInsertion()
		.build();

	Test.stopTest();

	Assert.isNull(account.Id, 'Parent should not be inserted');
	Assert.areEqual(2, account.Contacts.size(), 'Should have 2 contacts in memory');
	Assert.areEqual('Smith', account.Contacts[0].LastName);
	Assert.isNull(account.Contacts[0].Id, 'Children should not be inserted');
}

Bulk Parents with Children

apex
@IsTest
private static void testBulkParentsWithChildren()
{
	Test.startTest();

	// Create 10 accounts, each with 3 contacts
	List<Account> accounts = (List<Account>)TST_Builder.of(Account.SObjectType)
		.withCount(10)
		.withOverride(Account.Name, 'Bulk Parent')
		.withChildren(Contact.SObjectType, 3, new Map<SObjectField, Object>
		{
			Contact.LastName => 'Child'
		})
		.buildList();

	Test.stopTest();

	Assert.areEqual(10, accounts.size());

	// Verify each account has 3 contacts
	Set<Id> accountIds = new Map<Id, Account>(accounts).keySet();
	List<Contact> allContacts = QRY_Builder.selectFrom(Contact.SObjectType)
		.fields(new List<SObjectField>{Contact.Id, Contact.AccountId})
		.condition(Contact.AccountId).isIn(new List<Id>(accountIds))
		.toList();

	// Group contacts by account to verify counts
	Map<Id, List<Contact>> contactsByAccount = new Map<Id, List<Contact>>();
	for(Contact contact : allContacts)
	{
		if(!contactsByAccount.containsKey(contact.AccountId))
		{
			contactsByAccount.put(contact.AccountId, new List<Contact>());
		}
		contactsByAccount.get(contact.AccountId).add(contact);
	}

	for(Account account : accounts)
	{
		Assert.areEqual(3, contactsByAccount.get(account.Id).size(), 'Each account should have 3 contacts');
	}
}

Optional and Defaulted Fields

By default, optional fields are left empty. Use these methods to force one to be populated, or to stop a normally-populated field from being set:

Force Optional Fields to be Populated

apex
@IsTest
private static void testDefaultedFields()
{
	Test.startTest();

	// Force Description (optional field) to be auto-populated
	Account account = (Account)TST_Builder.of(Account.SObjectType)
		.withOverride(Account.Name, 'Test Account')
		.withDefaultedField(Account.Description)
		.withDefaultedField(Account.Website)
		.build();

	Test.stopTest();

	Assert.isNotNull(account.Description, 'Optional field should be populated');
	Assert.isNotNull(account.Website, 'Optional field should be populated');
}

Using List:

apex
Account account = (Account)TST_Builder.of(Account.SObjectType)
	.withDefaultedFields(new List<Object>
	{
		Account.Description,
		Account.Industry,
		Account.Website
	})
	.build();

Multi-Level Relationship Paths

To populate a field on a parent or grandparent record, write the path with dots. The framework accepts either relationship names (such as 'Account.Parent') or field names (such as 'AccountId.ParentId'), and you can mix the two:

apex
@IsTest
private static void testMultiLevelRelationships()
{
	Test.startTest();

	// Using relationship names (traditional style)
	Contact contact1 = (Contact)TST_Builder.of(Contact.SObjectType)
		.withDefaultedField('Account.Parent.Name')  // Account -> Parent Account -> Name
		.build();

	// Using field names (also supported)
	Contact contact2 = (Contact)TST_Builder.of(Contact.SObjectType)
		.withDefaultedField('AccountId.ParentId.Name')  // Same result as above
		.build();

	// Mixed format also works
	Contact contact3 = (Contact)TST_Builder.of(Contact.SObjectType)
		.withDefaultedField('AccountId.Parent.Name')  // Field name + relationship name
		.build();

	Test.stopTest();

	// All three create the same hierarchy: Contact -> Account -> Parent Account
	Assert.isNotNull(contact1.Account.ParentId, 'Parent Account should be created');
	Assert.isNotNull(contact1.Account.Parent.Name, 'Parent Account Name should be populated');
}

Custom Lookup Fields:

apex
// For custom lookups, both formats work
// Relationship name: Lookup__r.Lookup__r.Name
// Field name: Lookup__c.Lookup__c.Name

Foobar__c record = (Foobar__c)TST_Builder.of(Foobar__c.SObjectType)
	.withDefaultedField('Lookup__c.Lookup__c.Name')  // Using field names
	.build();

Polymorphic Fields:

Some lookup fields can point to more than one object type. For example, OwnerId can be a User or a Queue. The framework can't guess which one you want, so it skips these fields automatically and logs an informational message:

apex
@IsTest
private static void testPolymorphicFieldHandling()
{
	// OwnerId is polymorphic (can be User or Queue)
	// The framework will skip it and log an info message
	Case caseRecord = (Case)TST_Builder.of(Case.SObjectType)
		.withDefaultedField(Case.OwnerId)  // Skipped - polymorphic
		.withDefaultedField(Case.AccountId)  // Populated normally
		.build();

	// OwnerId is not auto-populated due to polymorphism
	// Use withOverride() to set an explicit value if needed
	Case caseWithOwner = (Case)TST_Builder.of(Case.SObjectType)
		.withOverride(Case.OwnerId, UserInfo.getUserId())  // Explicit value
		.build();
}

Mark Required Fields as Optional

Stop the framework from filling in a field it would normally populate:

apex
@IsTest
private static void testOptionalFields()
{
	Test.startTest();

	// Prevent BusinessHoursId (normally auto-populated) from being set
	Case caseRecord = (Case)TST_Builder.of(Case.SObjectType)
		.withOptionalField(Case.BusinessHoursId)
		.withoutInsertion()
		.build();

	Test.stopTest();

	Assert.isNull(caseRecord.BusinessHoursId, 'Field marked optional should be null');
}

Using List:

apex
Case caseRecord = (Case)TST_Builder.of(Case.SObjectType)
	.withOptionalFields(new List<Object>
	{
		Case.BusinessHoursId,
		Case.EntitlementId
	})
	.withoutInsertion()
	.build();

Global Optional Fields (transaction-wide):

apex
@IsTest
private static void testGlobalOptionalFields()
{
	// Mark Phone as optional for ALL builds in this transaction
	TST_Builder.optionalFields.add('Phone');

	Account account1 = (Account)TST_Builder.of(Account.SObjectType)
		.withoutInsertion()
		.build();

	Account account2 = (Account)TST_Builder.of(Account.SObjectType)
		.withoutInsertion()
		.build();

	Assert.isNull(account1.Phone);
	Assert.isNull(account2.Phone);
}

Advanced Features

Mock ID Generation for Query Mocking

Sometimes you need a record that has an Id but was never saved to the database, for example when you mock a query so your test runs without DML. withoutInsertion(true) gives you exactly that: a record with a generated mock Id and no database write.

Difference between withoutInsertion() and withoutInsertion(true):

MethodID GeneratedUse Case
withoutInsertion()No (null)Simple in-memory objects for unit testing
withoutInsertion(true)Yes (mock ID)Query mocking, code that requires valid IDs

Example - Creating mock records with IDs:

apex
@IsTest
private static void testMockIdGeneration()
{
	Test.startTest();

	// withoutInsertion() - No ID generated
	Account accountNoId = (Account)TST_Builder.of(Account.SObjectType)
		.withOverride(Account.Name, 'No ID Account')
		.withoutInsertion()
		.build();

	Assert.isNull(accountNoId.Id, 'ID should be null');

	// withoutInsertion(true) - Mock ID generated
	Account accountWithMockId = (Account)TST_Builder.of(Account.SObjectType)
		.withOverride(Account.Name, 'Mock ID Account')
		.withoutInsertion(true)
		.build();

	Assert.isNotNull(accountWithMockId.Id, 'Mock ID should be generated');
	Assert.areEqual(18, accountWithMockId.Id.length(), 'Should be valid 18-char ID');

	Test.stopTest();
}

Using with Query Mocking:

apex
@IsTest
private static void testWithQueryMocking()
{
	// Create mock records with IDs for query mocking
	List<Account> mockAccounts = new List<Account>();
	for(Integer i = 0; i < 5; i++)
	{
		Account mockAccount = (Account)TST_Builder.of(Account.SObjectType)
			.withOverrides(new Map<SObjectField, Object>
			{
				Account.Name => 'Mock Account ' + i,
				Account.Industry => 'Technology'
			})
			.withoutInsertion(true) // Generate mock ID
			.build();
		mockAccounts.add(mockAccount);
	}

	// Configure query mock (see Selectors - Guide for full details)
	QRY_Builder.setMock(Account.SObjectType, mockAccounts);

	Test.startTest();
	// All QRY_Builder calls for Account now return mock data
	List<Account> results = QRY_Builder.selectFrom(Account.SObjectType)
		.fields(new List<SObjectField>{Account.Name, Account.Industry})
		.toList();
	Test.stopTest();

	Assert.areEqual(5, results.size());
	Assert.isNotNull(results[0].Id, 'Mock records have IDs');

	// Cleanup
	QRY_Builder.clearMocks();
}

When to use withoutInsertion(true):

  • Query mocking scenarios (records need realistic IDs)
  • Testing code that validates record.Id != null
  • Creating records for Map keys or Set membership based on Id
  • Testing relationship lookups where foreign key Ids are required

See Also: Selectors - Guide for complete Query Mocking documentation.


Auto-Default Marker

When you pass an override map but want one field to keep its auto-generated value, set that field to autoDefaultFieldValueProvider:

apex
Account account = (Account)TST_Builder.of(Account.SObjectType)
	.withOverrides(new Map<String, Object>
	{
		'Name' => 'Test Account',
		'Description' => TST_Builder.autoDefaultFieldValueProvider // Auto-generate value
	})
	.withoutInsertion()
	.build();

Assert.isNotNull(account.Description, 'Auto-default marker triggers value generation');

Custom Default Value Provider

When the built-in defaults don't suit your org, you can change how default values are generated by extending TST_Builder.DefaultValueProvider:

apex
public inherited sharing class CustomDefaultProvider extends TST_Builder.DefaultValueProvider
{
	public override Map<String, TST_Builder.DefaultFieldValueProvider> getDefaultMapOfValues(
		SObjectType sObjectType,
		Map<String, Object> mapOfValuesOverride)
	{
		Map<String, TST_Builder.DefaultFieldValueProvider> defaults = super.getDefaultMapOfValues(sObjectType, mapOfValuesOverride);

		// Custom logic: Always use specific domain for email fields
		if(sObjectType == Contact.SObjectType)
		{
			defaults.put('Email', new StaticValueProvider('test@mycustomdomain.com'));
		}

		return defaults;
	}
}

// In test setup
TST_Builder.defaultValueProvider = new CustomDefaultProvider();

Custom Factory Provider

For full control over how records are created, supply your own factory provider. This is an advanced option most tests won't need:

apex
public inherited sharing class CustomFactoryProvider implements TST_Builder.FactoryProvider
{
	public TST_Builder.Factory createFactory(SObjectType sObjectType)
	{
		// Return custom factory implementation
		return new MyCustomFactory(sObjectType);
	}
}

// In test setup
TST_Builder.factoryProvider = new CustomFactoryProvider();

Complete Example

This example pulls several features together: a record type, field overrides, optional fields, and two kinds of children.

apex
@IsTest
private static void testCompleteExample()
{
	Test.startTest();

	// Create account with:
	// - Specific record type
	// - Field overrides
	// - Optional field population
	// - Multiple child types
	Account account = (Account)TST_Builder.of(Account.SObjectType)
		.withRecordType('Enterprise_Account')
		.withOverrides(new Map<SObjectField, Object>
		{
			Account.Name => 'Enterprise Corp',
			Account.Industry => 'Technology',
			Account.AnnualRevenue => 5000000
		})
		.withDefaultedField(Account.Description)
		.withDefaultedField(Account.Website)
		.withChildren(Contact.SObjectType, 3, new Map<SObjectField, Object>
		{
			Contact.LastName => 'Executive',
			Contact.Department => 'Leadership'
		})
		.withChildren(
			TST_Builder.of(Opportunity.SObjectType)
				.withCount(2)
				.withOverride(Opportunity.StageName, 'Prospecting')
				.withOverride(Opportunity.CloseDate, Date.today().addDays(30))
				.withDefaultedField(Opportunity.Description)
		)
		.build();

	Test.stopTest();

	// Verify parent
	Assert.isNotNull(account.Id);
	Assert.areEqual('Enterprise Corp', account.Name);
	Assert.isNotNull(account.Description, 'Optional field should be populated');

	// Verify children
	List<Contact> contacts = QRY_Builder.selectFrom(Contact.SObjectType)
		.fields(new List<SObjectField>{Contact.Id, Contact.LastName})
		.condition(Contact.AccountId).equals(account.Id)
		.toList();
	List<Opportunity> opportunities = QRY_Builder.selectFrom(Opportunity.SObjectType)
		.fields(new List<SObjectField>{Opportunity.Id, Opportunity.StageName})
		.condition(Opportunity.AccountId).equals(account.Id)
		.toList();

	Assert.areEqual(3, contacts.size());
	Assert.areEqual('Executive', contacts[0].LastName);
	Assert.areEqual(2, opportunities.size());
	Assert.areEqual('Prospecting', opportunities[0].StageName);
}

Bulk Utilities (UTIL_BulkUpdates & UTIL_PurgeRecords)

Bulk Field Updates

When you want to apply the same field change to many records, UTIL_BulkUpdates does it in batches for you, so you don't write the query-loop-and-save code yourself.

Example:

apex
// Invalidate email fields in bulk
// Invalidate all Account email addresses
UTIL_BulkUpdates.invalidateEmailFields(Account.PersonEmail);

// Invalidate Contact emails with custom batch size
UTIL_BulkUpdates.invalidateEmailFields(Contact.Email, 200);

// Invalidate with all-or-nothing transaction control
UTIL_BulkUpdates.invalidateEmailFields('Lead', 'Email', 100, true);

Bulk Owner Updates:

apex
// Update record ownership in bulk
UTIL_BulkUpdates.updateOwner('Account', 'Support Rep', 'jane.smith@example.com', 200, true);

Generic Field Updates:

apex
// Update any field with search conditions
// Archive all Closed Won opportunities with Status__c = 'Archived'
QRY_Condition.Evaluable searchConditions = new QRY_Condition.AndCondition()
	.add(new QRY_Condition.FieldCondition('StageName', QRY_Condition.Operator.EQUALS, 'Closed Won'));

UTIL_BulkUpdates.updateField(
	'Opportunity',
	'Status__c',
	'Archived',
	searchConditions,
	200,
	false
);

Deactivate Users in Bulk:

apex
// Deactivate inactive users by profile
// Deactivate users inactive for 180 days
Set<String> profiles = new Set<String>{'Standard User', 'Chatter Free User'};
UTIL_BulkUpdates.deactivateUsers(profiles, 180);

// With custom batch size and all-or-nothing
Set<String> supportProfiles = new Set<String>{'Support Rep'};
UTIL_BulkUpdates.deactivateUsers(
	supportProfiles,
	90,
	200,
	true
);

Purge Records (UTIL_PurgeRecords)

Clean up data by deleting every record of a type, or only the records older than a cutoff.

Example:

apex
// Purge old records using batch processing
// Delete all test data
UTIL_PurgeRecords.deleteAllRecords(Account.SObjectType);

// Delete records older than 90 days
UTIL_PurgeRecords.deleteOlderThanNDays('Contact', 90);

// Delete records older than 30 days based on custom date field
UTIL_PurgeRecords.deleteOlderThanNDays('Task', 'ActivityDate', 30);

// Delete with specific batch size and atomicity
UTIL_PurgeRecords.deleteAllRecords('Lead', false, 200);

Deactivate Users

Deactivate users who have been inactive, in batches, and on a schedule if you want it to run regularly.

Example:

apex
// Schedule user deactivation job
// Schedule job to deactivate users inactive for 180 days
String cronExpression = '0 0 2 * * ?'; // Daily at 2 AM

SCHED_DeactivateUsers job = new SCHED_DeactivateUsers();
job.setAttributes(new DTO_NameValues(new Map<String, String>
{
	'profileNames' => 'Standard User|Chatter Free User',
	'minimumNumberOfDays' => '180',
	'batchSize' => '200',
	'allOrNothing' => 'false'
}));

System.schedule('Deactivate Inactive Users', cronExpression, job);

Batch Processing

For very large volumes, run your writes in batch Apex and call DML_Builder inside each batch chunk.

Example:

apex
// Custom batch for bulk updates
public with sharing class BATCH_UpdateAccountIndustry implements Database.Batchable<SObject>
{
	public Database.QueryLocator start(Database.BatchableContext context)
	{
		return QRY_Builder.selectFrom(Account.SObjectType)
			.fields(new List<SObjectField>{Account.Id, Account.Industry})
			.condition(Account.Industry).isNull()
			.toQueryLocator();
	}

	public void execute(Database.BatchableContext context, List<Account> scope)
	{
		for(Account account : scope)
		{
			account.Industry = 'Other';
		}

		// Use DML_Builder for bulk update with sharing bypass
		DML_Builder.newTransaction()
			.doUpdate(scope)
			.bypassSharing()
			.allowPartial()
			.execute();
	}

	public void finish(Database.BatchableContext context)
	{
		LOG_Builder.build().info('Batch complete').emitAt('BATCH_UpdateAccountIndustry.finish');
	}
}

// Execute batch
Database.executeBatch(new BATCH_UpdateAccountIndustry(), 200);

Testing

To test code that writes data, you have two tools. TST_Builder creates the test records, and TST_Mock lets you stand in mock query results so a test can run without touching the database. Building records through the builder instead of inline DML keeps tests short and easy to read.

Testing DML_Builder operations:

apex
@IsTest(SeeAllData=false IsParallel=true)
private class MyService_TEST
{
	@IsTest
	private static void shouldInsertRecordsSuccessfully()
	{
		List<Foobar__c> records = (List<Foobar__c>)TST_Builder.of(Foobar__c.SObjectType)
			.withCount(3)
			.withoutInsertion()
			.buildList();

		Test.startTest();
		DML_Builder.TransactionResult result = DML_Builder.newTransaction()
			.doInsert(records)
			.execute();
		Test.stopTest();

		Assert.isTrue(result.isSuccess(), 'All records should insert successfully');
	}
}

Testing transactional DML:

apex
@IsTest
private static void shouldCommitRelatedRecords()
{
	Foobar__c parent = (Foobar__c)TST_Builder.of(Foobar__c.SObjectType)
		.withoutInsertion()
		.build();

	Test.startTest();
	DML_Builder.newTransaction()
		.doInsert(parent)
		.execute();
	Test.stopTest();

	List<Foobar__c> inserted = QRY_Builder.selectFrom(Foobar__c.SObjectType).toList();
	Assert.areEqual(1, inserted.size(), 'One record should be committed');
}

Testing with mock data (no DML):

When your code reads data through selectors, register mock records with TST_Mock so the selector returns them. The test skips DML entirely, which keeps it fast:

apex
@IsTest
private static void shouldProcessMockedRecords()
{
	Foobar__c mock = (Foobar__c)TST_Mock.of(Foobar__c.SObjectType)
		.withOverride(Foobar__c.Name, 'Test Record')
		.build();

	Foobar__c result = (Foobar__c)new SEL_Foobar().findById(mock.Id);

	Assert.areEqual('Test Record', result.Name, 'Should return mocked record');

	TST_Mock.clear();
}

Capability Matrix (for Analysts)

What the data layer can control, and the exact method or record that controls it.

Full capability matrix
CapabilityControl PointClass/MethodNotes
Transactional DMLUnit of Work patternDML_Builder.newTransaction()Commits all changes atomically
USER_MODE defaultDatabase-level access enforcementDML_Builder (default)CRUD + FLS + sharing enforced; shipped via FeatureFlag.UserModeDml_Enabled = true
USER_MODE / SYSTEM_MODE (per-op)Access-mode override.withUserMode() / .withSystemMode()Explicit per-transaction override of the flag-driven default
Access-mode kill-switchMetadata-only emergency rollbackFeatureFlag.UserModeDml_EnabledFlip IsEnabledByDefault__c = false to revert all DML to SYSTEM_MODE
Sharing enforcement (per-op)Proxy class routing.bypassSharing()Routes through without sharing proxy (only meaningful in SYSTEM_MODE)
Async DML preserves access modeAccess-level propagation.async().execute()The chosen access mode propagates through queueable execution
Partial successError handling.allowPartial()Continues on individual record failures
Object permission checkingFlow invocableFLOW_CheckObjectPermissionsValidates CRUD before DML in Flows
Field-level securityQuery-level enforcementQRY_Builder.withUserMode() / .stripInaccessible()FLS enforcement at the query level
Parent-child chainingRelationship linking.doInsert(child, field, parent)Auto-sets lookup after parent insert

Anti-Patterns

Common mistakes when writing data by hand, and what to do instead.

Full anti-pattern table
Anti-PatternWhy It's WrongInstead
Raw insert/update/delete statementsBypasses sharing enforcement, error handling, and framework consistencyUse DML_Builder.newTransaction().doInsert(records).execute()
Separate DML calls for related objectsIf the second DML fails, the first stays committed, with no way to roll it backUse DML_Transaction (Unit of Work) to commit all changes atomically
DML inside a loopHits governor limits on bulk operationsCollect records into a list, then perform a single bulk DML call
Ignoring Database.SaveResult errorsSilent failures corrupt data and hide bugsUse .allowPartial() with DML_Builder and log failures via LOG_Builder
Performing DML in a selector or query classViolates separation of concerns and makes the selector untestable in isolationKeep DML in trigger actions, service classes, or controllers

Best Practices

Use Transactional DML for Complex Transactions

When you save several related objects, register them in one DML_Builder.newTransaction() so a partial failure can't leave the data half-saved.

DO:

apex
// Use DML_Builder for related objects to maintain transactional integrity
Account account = new Account(Name = 'Test');
Contact contact = new Contact(LastName = 'Doe');

DML_Builder.newTransaction()
	.doInsert(account)
	.doInsert(contact, Contact.AccountId, account)
	.execute();

DON'T:

apex
// Don't manually manage relationships - leads to poor error handling and inconsistency
Account account = new Account(Name = 'Test');
insert account;
Contact contact = new Contact(LastName = 'Doe', AccountId = account.Id);
insert contact; // If this fails, Account remains in database - no rollback

Always Use DML_Builder for DML

Route every write through the framework so sharing control and error handling are applied consistently, instead of scattering raw DML statements.

DO:

apex
// Use framework methods for consistent DML operations
DML_Builder.newTransaction().doInsert(account).execute();

DON'T:

apex
// Avoid direct DML - bypasses framework's sharing and error handling capabilities
insert account; // No sharing control, no standardized error handling

Be Explicit About Sharing

When security matters, state the sharing behaviour you want rather than relying on whatever the caller happens to use.

apex
// Explicitly control sharing enforcement for security-sensitive operations
// In public-facing code
DML_Builder.newTransaction().doInsert(records).execute();

// In system-level code
DML_Builder.newTransaction().doInsert(records).bypassSharing().execute();

Check Permissions Before DML

In user-facing features, check the user's permissions first so a forbidden action returns a clear message instead of an unhandled error.

apex
// Check user permissions before attempting DML operations
FLOW_CheckObjectPermissions.DTO_Request request = new FLOW_CheckObjectPermissions.DTO_Request();
request.objectApiName = 'Account';
List<FLOW_CheckObjectPermissions.DTO_Response> perms =
	FLOW_CheckObjectPermissions.checkPermissions(new List<FLOW_CheckObjectPermissions.DTO_Request>{request});

if(perms[0].hasCreateAccess)
{
	// Proceed with DML
}

Use TST_Builder in Tests

Build test data with the builder instead of hand-written inserts, so your tests stay short and easy to read.

DO:

apex
// Use builder pattern with type-safe field tokens and parent-child relationships
@IsTest
private static void testMethod()
{
	// Create account with related contacts using builder
	Account account = (Account)TST_Builder.of(Account.SObjectType)
		.withOverrides(new Map<SObjectField, Object>
		{
			Account.Name => 'Test Account',
			Account.Industry => 'Technology'
		})
		.withChildren(Contact.SObjectType, 3, new Map<SObjectField, Object>
		{
			Contact.LastName => 'Test Contact'
		})
		.build();

	// Test with properly structured data
	Assert.isNotNull(account.Id);
	Integer contactCount = QRY_Builder.selectFrom(Contact.SObjectType)
		.condition(Contact.AccountId).equals(account.Id)
		.toList()
		.size();
	Assert.areEqual(3, contactCount);
}

Use these builder features:

  • Type-safe overrides. Use SObjectField tokens instead of strings (for example Account.Name, not 'Name') so a typo is caught at compile time.
  • Parent-child relationships. Use withChildren() to build a parent and its children together.
  • Record types. Use withRecordType('DeveloperName') to test record-type-specific behaviour.
  • Bulk creation. Use withCount() and buildList() for bulk scenarios.
  • Optional field control. Use withDefaultedField() or withOptionalField() to decide which optional fields get set.

DON'T:

apex
// Avoid manual DML and relationship management in tests
@IsTest
private static void testMethod()
{
	Account account = new Account(Name = 'Test');
	insert account; // Manual DML - verbose, no defaults

	List<Contact> contacts = new List<Contact>();
	for(Integer i = 0; i < 3; i++)
	{
		contacts.add(new Contact(LastName = 'Test', AccountId = account.Id));
	}
	insert contacts; // Separate DML - not atomic, harder to maintain
}

Handle DML Errors Properly

Check the results of every write so failures don't pass silently. The Database.SaveResult/Database.DeleteResult objects carry the per-row errors.

apex
// Always check and handle DML operation results
DML_Builder.newTransaction()
	.doInsert(accounts)
	.allowPartial()
	.execute();

Use Bulk Operations

Save a whole list of records in one call rather than one record at a time, so a loop can't blow the limit on DML statements.

DO:

apex
// Process records in bulk to avoid governor limits
List<Account> accounts = getAccountsToUpdate();
DML_Builder.newTransaction().doUpdate(accounts).execute();

DON'T:

apex
// Don't process records individually in a loop - causes governor limit violations
for(Account account : accounts)
{
	DML_Builder.newTransaction().doUpdate(account).execute(); // GOVERNOR LIMIT VIOLATION - Too many DML statements
}

Use Batch Apex for Large Volumes

For operations on thousands of records, run them in batch Apex so each chunk stays within platform limits.

apex
// Use batch processing for large-scale operations
Database.executeBatch(new BATCH_ProcessRecords(), 200);

Use UTIL_BulkUpdates for Common Bulk Operations

For common bulk updates, use the built-in utilities instead of writing your own query-and-loop code each time.

DO:

apex
// Use utility methods for common bulk operations
// Invalidate all Contact email addresses with built-in batch processing
UTIL_BulkUpdates.invalidateEmailFields(Contact.Email, 200);

DON'T:

apex
// Don't reinvent the wheel with custom batch classes for common operations
// Bad: Manual query + loop + single DML (no batch processing, no error handling)
List<Contact> contacts = QRY_Builder.selectFrom(Contact.SObjectType)
	.fields(new List<SObjectField>{Contact.Id, Contact.Email})
	.condition(Contact.Email).isNotNull()
	.toList();
for(Contact contact : contacts)
{
	contact.Email = contact.Email + '.invalid';
}
update contacts; // Missing batch processing, error handling, transaction control

Create Fresh Transactions

Start a fresh DML_Builder.newTransaction() for each separate unit of work; each call gives you a clean transaction with nothing carried over from the last.

apex
// Create a new transaction for each logical unit of work
DML_Builder.newTransaction()
	.doInsert(firstBatch)
	.execute();

// New transaction for separate operation
DML_Builder.newTransaction()
	.doUpdate(secondBatch)
	.execute();

Document DML Operations

Give every DML method clear ApexDoc, so the next person knows what it does and how to call it.

apex
/**
 * @description Creates a new account with related contacts
 *
 * @param accountName Name for the new account
 * @param contactNames List of contact last names
 *
 * @return The created Account record with Id populated
 *
 * @example
 * ```apex
 * Account account = createAccountWithContacts('Acme Corp', new List&lt;String&gt;{'Smith', 'Jones'});
 * ```

*/
public static Account createAccountWithContacts(String accountName, List<String> contactNames)
{
// Implementation
}

Use All-or-Nothing Appropriately

Decide whether everything must succeed together (all-or-nothing) or whether the good rows can commit while bad ones are reported (partial). Pick based on what the work needs.

apex
// Choose between atomic and partial commits based on business requirements
// Atomic - all must succeed (default)
DML_Builder.newTransaction().doInsert(records).execute();

// Partial - allow some to succeed
DML_Builder.newTransaction().doInsert(records).allowPartial().execute();

Reset Sharing After Operations

Set sharing per operation rather than flipping a shared, global setting, so one write can't accidentally change the behaviour of another.

apex
// Prefer operation-level sharing control
DML_Builder.newTransaction()
	.doInsert(records)
	.execute();

Use Purge Utilities for Cleanup

For data cleanup, use the built-in purge utilities instead of hand-written delete logic.

apex
// Use purge utilities for data cleanup operations
// Clean up test data in tests
@TestSetup
static void setupTestData()
{
	// Create test data
}

@IsTest
static void testCleanup()
{
	Test.startTest();
	UTIL_PurgeRecords.deleteAllRecords(Account.SObjectType);
	Test.stopTest();
}