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.newin 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_Builderadds overhead without a real benefit. RawDatabase.*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:
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:
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 need | Use | See |
|---|---|---|
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 transaction | DML_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 directly | TransactionResult.getErrors() returns the underlying platform errors after .execute(). | Anti-Patterns |
| Skip the framework entirely for one edge case | Database.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
- What problem does this solve?
- Mental model
- Use this when
- Don't use this when
- Quick Start
- How to opt out
- Quick Navigation
- Why choose this over the built-in option?
- How does it work?
- Transactional DML Pattern (DML_Builder)
- Bulk DML Operations (DML_Builder)
- Sharing Enforcement
- Permission Checking (FLOW_CheckObjectPermissions)
- Test Data Factory
- Bulk Utilities (UTIL_BulkUpdates & UTIL_PurgeRecords)
- Testing
- Capability Matrix (for Analysts)
- Anti-Patterns
- Best Practices
- Use Transactional DML for Complex Transactions
- Always Use DML_Builder for DML
- Be Explicit About Sharing
- Check Permissions Before DML
- Use TST_Builder in Tests
- Handle DML Errors Properly
- Use Bulk Operations
- Use Batch Apex for Large Volumes
- Use UTIL_BulkUpdates for Common Bulk Operations
- Create Fresh Transactions
- Document DML Operations
- Use All-or-Nothing Appropriately
- Reset Sharing After Operations
- Use Purge Utilities for Cleanup
- Related Documentation
Quick Navigation
| I am a... | I need to... | Go to... |
|---|---|---|
| Architect | Design transaction patterns | How does it work? |
| Architect | Understand sharing enforcement | Sharing Enforcement |
| Developer | Perform DML operations | Quick Start |
| Developer | Build test data | Test Data Factory |
| Developer | Handle complex transactions | Transactional DML Pattern |
| Analyst | Understand permission checking | Permission Checking |
| Analyst | Know DML capabilities | Capability 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:
DML_Transactionkeeps a complex, multi-object save together: register everything, then commit it all or roll it all back (the Unit of Work pattern).DML_Builderis the everyday tool you call. It runs bulk inserts, updates, and deletes and lets you decide how sharing applies.- Sharing Proxy decides whether a write respects the running user's record visibility or runs with elevated access.
FLOW_CheckObjectPermissionsconfirms 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_Builderbuilds test records (with required fields filled in for you) so test setup stays short.UTIL_PurgeRecordsdeletes records in bulk for cleanup.UTIL_BulkUpdatesapplies 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
+---------------------------------------------------------------------------+
| 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:
// 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 maintainedLayer 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:
// 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_Enabledrecord .withUserMode()/.withSystemMode()to pick the security level explicitly.bypassSharing()to route a write through awithout sharingpath (in SYSTEM_MODE)- Per-operation control through short chained method calls
- AccessLevel.SYSTEM_MODE support
Example:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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__cMultiple Records:
// 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 matchingWith Parent-Child Relationships:
// 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):
// 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 transactionMixed 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:
// 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:
// 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:
// 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:
// 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):
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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 accessPermission 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:
// 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:
// 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:
// 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
SObjectFieldtokens. - Creates many records at once. Use
withCount()andbuildList()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:
@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:
@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:
@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:
@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):
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():
@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:
@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:
@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:
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)
@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
@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)
@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
@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:
@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)
@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
@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
@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:
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:
@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:
// 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:
@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:
@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:
Case caseRecord = (Case)TST_Builder.of(Case.SObjectType)
.withOptionalFields(new List<Object>
{
Case.BusinessHoursId,
Case.EntitlementId
})
.withoutInsertion()
.build();Global Optional Fields (transaction-wide):
@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):
| Method | ID Generated | Use 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:
@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:
@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:
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:
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:
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.
@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:
// 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:
// Update record ownership in bulk
UTIL_BulkUpdates.updateOwner('Account', 'Support Rep', 'jane.smith@example.com', 200, true);Generic Field Updates:
// 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:
// 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:
// 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:
// 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:
// 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:
@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:
@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:
@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
| Capability | Control Point | Class/Method | Notes |
|---|---|---|---|
| Transactional DML | Unit of Work pattern | DML_Builder.newTransaction() | Commits all changes atomically |
| USER_MODE default | Database-level access enforcement | DML_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-switch | Metadata-only emergency rollback | FeatureFlag.UserModeDml_Enabled | Flip 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 mode | Access-level propagation | .async().execute() | The chosen access mode propagates through queueable execution |
| Partial success | Error handling | .allowPartial() | Continues on individual record failures |
| Object permission checking | Flow invocable | FLOW_CheckObjectPermissions | Validates CRUD before DML in Flows |
| Field-level security | Query-level enforcement | QRY_Builder.withUserMode() / .stripInaccessible() | FLS enforcement at the query level |
| Parent-child chaining | Relationship 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-Pattern | Why It's Wrong | Instead |
|---|---|---|
Raw insert/update/delete statements | Bypasses sharing enforcement, error handling, and framework consistency | Use DML_Builder.newTransaction().doInsert(records).execute() |
| Separate DML calls for related objects | If the second DML fails, the first stays committed, with no way to roll it back | Use DML_Transaction (Unit of Work) to commit all changes atomically |
| DML inside a loop | Hits governor limits on bulk operations | Collect records into a list, then perform a single bulk DML call |
Ignoring Database.SaveResult errors | Silent failures corrupt data and hide bugs | Use .allowPartial() with DML_Builder and log failures via LOG_Builder |
| Performing DML in a selector or query class | Violates separation of concerns and makes the selector untestable in isolation | Keep 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:
// 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:
// 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 rollbackAlways 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:
// Use framework methods for consistent DML operations
DML_Builder.newTransaction().doInsert(account).execute();DON'T:
// Avoid direct DML - bypasses framework's sharing and error handling capabilities
insert account; // No sharing control, no standardized error handlingBe Explicit About Sharing
When security matters, state the sharing behaviour you want rather than relying on whatever the caller happens to use.
// 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.
// 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:
// 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
SObjectFieldtokens instead of strings (for exampleAccount.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()andbuildList()for bulk scenarios. - Optional field control. Use
withDefaultedField()orwithOptionalField()to decide which optional fields get set.
DON'T:
// 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.
// 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:
// Process records in bulk to avoid governor limits
List<Account> accounts = getAccountsToUpdate();
DML_Builder.newTransaction().doUpdate(accounts).execute();DON'T:
// 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.
// 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:
// 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:
// 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 controlCreate 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.
// 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.
/**
* @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<String>{'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.
// 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.
// 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.
// 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();
}Related Documentation
- Selectors - Guide - Query patterns paired with DML operations
- Triggers - Guide - Trigger actions that perform DML via
DML_Builder - Web Services - Guide - API classes with DML operations
- Logging - Guide - DML error logging via
LOG_Builder.errorDMLOperationResults() - Validation - Guide - Validation before DML operations