fflib Domain, Service and Unit of Work
The write side of Apex Enterprise Patterns. Selectors read (skill sf-fflib-selector-layer);
Domain, Service and Unit of Work decide, validate and write.
All fflib_* signatures below were read from apex-enterprise-patterns/fflib-apex-common,
branch master, commit dab59777bac37f6b8a3cc2871fcf5df8d37764b4 (2026-09-10), under
sfdx-source/apex-common/main/classes/. Wiring examples were read from
apex-enterprise-patterns/fflib-apex-common-samplecode, branch master, commit
4657d6325b3d6ae41bc7af864ca4f41287e72112.
When to use
| Situation | Use this skill |
|---|---|
| Writing a trigger in an fflib org | Yes - one-line fflib_SObjectDomain.triggerHandler body |
| Record-level validation that must fire on every write path | Yes - domain onValidate |
| Orchestrating multi-object writes in one transaction | Yes - service + Unit of Work |
| Exposing logic to LWC, Flow, REST or Batch | Yes - thin entry point delegating to a service |
| Querying with field-level security and a query factory | No - skill sf-fflib-selector-layer |
Application factory wiring, fflib_ISObjectUnitOfWork mocking basics |
No - skill sf-fflib-foundations |
| Writing the tests for any of the above | No - skill sf-fflib-testing |
| Bypass switches, async runners, packaging, upgrades | No - skill sf-fflib-operations |
| Hand-rolled trigger handler frameworks, no fflib | No - skill sf-apex-development |
Decision table: where does the logic go?
| Logic | Layer | Why |
|---|---|---|
| Default a field on insert | Domain onApplyDefaults() |
Runs before insert, no DML needed |
| "Field A is required when B is set" | Domain onValidate() |
Must hold for every write path including Data Loader |
| "Field X cannot change after creation" | Domain onValidate(Map<Id,SObject> existingRecords) |
Needs old values |
| Compute a roll-up onto a parent | Domain method invoked by a service, writes via UoW | Needs cross-object transaction |
| "Close the opportunity and create the project" | Service method | Owns the transaction, spans objects |
| Query the records first | Selector, called by the service | Domain classes never query |
| Send the platform event | UoW registerPublishAfterSuccessTransaction |
Publish only if the transaction commits |
| Enqueue async follow-up | Service method returning the job Id | Keeps System.enqueueJob out of domain code |
| Convert records to a UI shape | Controller/DTO, not the domain | Domain holds behaviour, not presentation |
Core patterns
1. The one-line trigger
The trigger contains a single dispatch call. The dispatcher resolves the domain constructor,
constructs the domain over the correct record set and calls the matching handleXxx method.
trigger Opportunities on Opportunity (
before insert, before update, before delete,
after insert, after update, after delete, after undelete)
{
fflib_SObjectDomain.triggerHandler(OpportunitiesTriggerHandler.class);
}
triggerHandler(Type domainClass) resolves the constructor as
domainClassName.endsWith('Constructor') ? Type.forName(domainClassName) : Type.forName(domainClassName + '.Constructor'),
so every domain class registered for trigger dispatch must expose a public inner class named
Constructor implementing fflib_SObjectDomain.IConstructable (or IConstructable2 when the
SObjectType must be passed for empty lists). Compare with the hand-rolled dispatcher in skill
sf-apex-development: fflib replaces the if (Trigger.isBefore && Trigger.isInsert) ladder and the
handler interface with this single static call.
2. Domain class anatomy
public inherited sharing class OpportunitiesTriggerHandler extends fflib_SObjectDomain
{
public OpportunitiesTriggerHandler(List<Opportunity> sObjectList)
{
super(sObjectList);
// Opt out of the base-class CRUD check when a custom IDML enforces user mode instead
Configuration.disableTriggerCRUDSecurity();
}
public override void onApplyDefaults()
{
for (Opportunity opp : (List<Opportunity>) Records)
{
if (opp.CloseDate == null) { opp.CloseDate = System.today().addDays(30); }
}
}
public override void onValidate()
{
for (Opportunity opp : (List<Opportunity>) Records)
{
if (opp.Type != null && opp.Type.startsWith('Existing') && opp.AccountId == null)
{
opp.AccountId.addError(
error('Existing-customer Opportunities require an Account.', opp, Opportunity.AccountId));
}
}
}
public override void onValidate(Map<Id, SObject> existingRecords)
{
for (Opportunity opp : (List<Opportunity>) Records)
{
Opportunity existing = (Opportunity) existingRecords.get(opp.Id);
if (opp.Type != existing.Type)
{
opp.Type.addError(error('Opportunity Type is immutable.', opp, Opportunity.Type));
}
}
}
public class Constructor implements fflib_SObjectDomain.IConstructable
{
public fflib_SObjectDomain construct(List<SObject> sObjectList)
{
return new OpportunitiesTriggerHandler(sObjectList);
}
}
}
Records is a property returning getRecords() (inherited from fflib_SObjects); the domain owns
its own copy of the list. error(...) records the message in fflib_SObjectDomain.Errors so tests
can assert on validation without DML. Full dispatch table and every Configuration flag:
references/domain-layer.md.
3. Service owns the transaction
public interface IProjectsService
{
Set<Id> createFromOpportunities(Set<Id> opportunityIds);
}
public with sharing class ProjectsService
{
public static Set<Id> createFromOpportunities(Set<Id> opportunityIds)
{
return service().createFromOpportunities(opportunityIds);
}
private static IProjectsService service()
{
return (IProjectsService) Application.Service.newInstance(IProjectsService.class);
}
}
The static shim keeps call sites terse; Application.Service.newInstance returns the registered
implementation or a mock injected by setMock. The implementation creates the Unit of Work, hands
it to domain methods, and is the only place that calls commitWork(). Never call commitWork()
inside a domain method or a trigger context - the caller owns the transaction.
4. Unit of Work registration chain
public with sharing class ProjectsServiceImpl implements IProjectsService
{
public Set<Id> createFromOpportunities(Set<Id> opportunityIds)
{
fflib_ISObjectUnitOfWork uow = Application.UnitOfWork.newInstance();
List<Opportunity> opps = OpportunitiesSelector.newInstance()
.selectByIdWithLineItems(opportunityIds);
List<Project__c> created = new List<Project__c>();
for (Opportunity opp : opps)
{
Project__c project = new Project__c(
Name = opp.Name.left(80),
Account__c = opp.AccountId,
Stage__c = 'Planning');
uow.registerNew(project, Project__c.Opportunity__c, opp); // opp already has an Id
created.add(project);
Integer sequence = 1;
for (OpportunityLineItem line : opp.OpportunityLineItems)
{
ProjectTask__c task = new ProjectTask__c(
Name = line.Description == null ? 'Task ' + sequence : line.Description.left(80),
Sequence__c = sequence++);
uow.registerNew(task, ProjectTask__c.Project__c, project); // parent has no Id yet
}
uow.registerPublishAfterSuccessTransaction(
new ProjectEvent__e(Opportunity__c = opp.Id, Action__c = 'Created'));
}
uow.commitWork();
Set<Id> projectIds = new Set<Id>();
for (Project__c project : created) { projectIds.add(project.Id); }
return projectIds;
}
}
registerNew(SObject record, Schema.SObjectField relatedToParentField, SObject relatedToParentRecord)
registers the child and defers the lookup assignment until the parent is inserted. The order of
SObjectTypes passed to Application.UnitOfWork decides insert order, so Project__c must appear
before ProjectTask__c. Full API table, relationship variants and commit lifecycle:
references/unit-of-work.md.
5. Thin entry points
Every external caller is a shell over a service call. Controllers, invocable actions, REST resources and async runners add no business logic.
public with sharing class ProjectCreationController
{
@AuraEnabled
public static List<Id> createProjects(List<Id> opportunityIds)
{
try
{
return new List<Id>(ProjectsService.createFromOpportunities(new Set<Id>(opportunityIds)));
}
catch (Exception e)
{
throw new AuraHandledException(e.getMessage());
}
}
}
public with sharing class CreateProjectsAction
{
public class Request
{
@InvocableVariable(required=true) public Id opportunityId;
}
@InvocableMethod(label='Create Project From Opportunity' category='Projects')
public static void run(List<Request> requests)
{
Set<Id> ids = new Set<Id>();
for (Request r : requests) { ids.add(r.opportunityId); }
ProjectsService.createFromOpportunities(ids); // one bulk call, not one per request
}
}
LWC wiring is in skill sf-lwc-development; Flow invocable semantics and bulk behaviour in skill
sf-flow-automation; Queueable and Batch entry points in skills sf-async-apex-patterns and
sf-fflib-operations.
6. User-mode DML through a custom IDML
fflib_SObjectUnitOfWork.SimpleDML performs System.Database.insert(objList, AccessLevel.SYSTEM_MODE).
fflib_SObjectUnitOfWork.UserModeDML extends SimpleDML and defaults to AccessLevel.USER_MODE. Wire
it once in the Application factory rather than at every call site:
private class UserModeUnitOfWorkFactory extends fflib_Application.UnitOfWorkFactory
{
public UserModeUnitOfWorkFactory(List<SObjectType> objectTypes) { super(objectTypes); }
public override fflib_ISObjectUnitOfWork newInstance()
{
if (m_mockUow != null) { return m_mockUow; }
return new fflib_SObjectUnitOfWork(m_objectTypes, new fflib_SObjectUnitOfWork.UserModeDML());
}
}
This is exactly the pattern used by Application.cls in fflib-apex-common-samplecode. Permission
semantics and AccessLevel trade-offs: skill sf-security-model.
Anti-patterns
Committing inside the domain.
// WRONG - the domain does not own the transaction and cannot be composed
public override void onAfterInsert()
{
fflib_ISObjectUnitOfWork uow = Application.UnitOfWork.newInstance();
uow.registerNew(buildAuditRecords());
uow.commitWork();
}
// RIGHT - domain methods accept the caller's UoW
public void logAudit(fflib_ISObjectUnitOfWork uow)
{
uow.registerNew(buildAuditRecords());
}
Row-at-a-time service signatures.
// WRONG - callers loop, every loop iteration opens a transaction
void createProject(Id opportunityId);
// RIGHT - bulk-first, the service loops internally once
Set<Id> createFromOpportunities(Set<Id> opportunityIds);
SOQL inside a domain class. Domain classes receive records; they never query. Inject data via a
selector call in the service, or pass a pre-queried list. See skill sf-fflib-selector-layer.
Hand-rolled savepoints around a commitWork(). commitWork() already sets a savepoint, rolls
back on exception and rethrows. Wrapping it adds two more DML statements against the 150-statement
limit (Apex transaction control).
Only add an outer savepoint when you must keep earlier, already-committed work in the same
transaction.
registerDirty on a record the domain also mutates after registration. The UoW holds the same
reference; late mutations silently ship. Register last, or use the field-scoped
registerDirty(SObject, List<SObjectField>) overload.
Multiple commitWork() calls to "flush" work. Each call is a fresh savepoint plus DML per
SObject type. Register everything, commit once. Governor arithmetic: skill sf-governor-limits.
A second trigger on the same object. fflib assumes one trigger per object; a second trigger
re-enters the dispatcher with a different record set and breaks stateful domains. See skill
sf-fflib-operations for the inventory and migration procedure.
Verification
# Local gate: format, lint, Code Analyzer, Jest
node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" local --changed
# Static analysis only (PMD rules that misfire on fflib are listed in sf-fflib-operations)
node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" analyzer --changed
# Org tests plus coverage gates (apexOrgCoverageMin 85, apexClassCoverageMin 75)
node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" apex --target-org vf-dev
# Narrow run while iterating on one domain or service
sf apex run test --target-org vf-dev --synchronous --code-coverage --result-format human \
--tests ProjectsServiceTest --tests OpportunitiesTriggerHandlerTest
# Prove the trigger dispatches and the UoW commits in a real org
sf apex run --target-org vf-dev --file scripts/apex/create-projects-smoke.apex
sf data query --target-org vf-dev \
--query "SELECT Id, Name, Opportunity__c FROM Project__c ORDER BY CreatedDate DESC LIMIT 5"
Checklist before opening the PR:
| Check | Expected |
|---|---|
| Trigger body | Exactly one fflib_SObjectDomain.triggerHandler(...) call |
| Domain class | Has Constructor inner class implementing IConstructable |
| Domain class | No SOQL, no DML, no commitWork() |
| Service interface | Bulk signatures (Set<Id>, List<...>, Map<Id,...>) |
| Service impl | Creates UoW, calls commitWork() exactly once per public method |
| UoW type list | Parents before children in Application.UnitOfWork |
| Platform events | Registered with registerPublishAfterSuccessTransaction unless failure telemetry is wanted |
| Entry points | @AuraEnabled / @InvocableMethod contain no business logic |
References
- references/domain-layer.md - dispatch table, every override,
Configurationflags,TriggerEventtoggles, stateful domains,Test.Databasemock DML, error handling. - references/service-layer.md - interface/impl/controller/invocable chain for one feature, exception policy, transaction ownership, DTO rules.
- references/unit-of-work.md - full
fflib_ISObjectUnitOfWorkAPI table, relationship registration, platform events, customIDML, commit order, limits. - references/end-to-end-feature.md - one requirement implemented across Selector, Domain, Service, UoW, controller and invocable action, with every file.
Sibling skills: sf-fflib-foundations, sf-fflib-selector-layer, sf-fflib-testing,
sf-fflib-operations, sf-apex-development, sf-apex-testing, sf-async-apex-patterns,
sf-governor-limits, sf-security-model, sf-soql-sosl-optimization, sf-lwc-development,
sf-flow-automation, sf-integration-patterns, sf-code-analyzer-quality.
Upstream sources:
- fflib-apex-common -
master,sfdx-source/apex-common/main/classes/fflib_SObjectDomain.cls,fflib_SObjectUnitOfWork.cls,fflib_ISObjectUnitOfWork.cls,fflib_Application.cls,fflib_SObjects.cls. - fflib-apex-common-samplecode -
Application.cls,domains/Opportunities.cls,service/OpportunitiesServiceImpl.cls,triggerHandlers/OpportunitiesTriggerHandler.cls,triggers/Opportunities.trigger. - fflib.dev - layer conventions.
- Apex transaction control, Execution governors and limits, Publish platform events with Apex.