agentleFS
grzmolgrzmol/vibe-force/skills/sf-fflib-domain-service-uow/SKILL.md

sf-fflib-domain-service-uow

Skill2 starsChanged 2 months agoMITView on GitHub

Covers the write side of the fflib Apex Enterprise Patterns stack - fflib_SObjectDomain trigger domains (onApplyDefaults, onValidate, onBeforeInsert, onAfterUpdate, handleBeforeInsert dispatch, Configuration flags, DomainException), the service layer (IXxxService + XxxServiceImpl + Application.Service.newInstance, transaction ownership, thin @AuraEnabled and @InvocableMethod entry points), and fflib_SObjectUnitOfWork (registerNew, registerDirty, registerRelationship, registerPublishAfterSuccessTransaction, commitWork, custom IDML with AccessLevel.USER_MODE, SObject commit ordering). Use this skill when writing or reviewing a one-line fflib trigger, a domain class with a Constructor inner class, a service that owns commitWork, a Unit of Work registration chain, or when deciding where a piece of business logic belongs in an fflib org.

The file

We split this skill into the rules it gives an agent

2 rules. About 0 are common, 2 are distinctive, and 0 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈2

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 0

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Every external caller is a shell over a service call.

    line 209
  2. Distinctive:

    Only add an outer savepoint when you must keep earlier, already-committed work in the same transaction.

    line 314

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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

SituationUse this skill
Writing a trigger in an fflib orgYes - one-line fflib_SObjectDomain.triggerHandler body
Record-level validation that must fire on every write pathYes - domain onValidate
Orchestrating multi-object writes in one transactionYes - service + Unit of Work
Exposing logic to LWC, Flow, REST or BatchYes - thin entry point delegating to a service
Querying with field-level security and a query factoryNo - skill sf-fflib-selector-layer
Application factory wiring, fflib_ISObjectUnitOfWork mocking basicsNo - skill sf-fflib-foundations
Writing the tests for any of the aboveNo - skill sf-fflib-testing
Bypass switches, async runners, packaging, upgradesNo - skill sf-fflib-operations
Hand-rolled trigger handler frameworks, no fflibNo - skill sf-apex-development

Decision table: where does the logic go?

LogicLayerWhy
Default a field on insertDomain 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 parentDomain method invoked by a service, writes via UoWNeeds cross-object transaction
"Close the opportunity and create the project"Service methodOwns the transaction, spans objects
Query the records firstSelector, called by the serviceDomain classes never query
Send the platform eventUoW registerPublishAfterSuccessTransactionPublish only if the transaction commits
Enqueue async follow-upService method returning the job IdKeeps System.enqueueJob out of domain code
Convert records to a UI shapeController/DTO, not the domainDomain 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:

CheckExpected
Trigger bodyExactly one fflib_SObjectDomain.triggerHandler(...) call
Domain classHas Constructor inner class implementing IConstructable
Domain classNo SOQL, no DML, no commitWork()
Service interfaceBulk signatures (Set<Id>, List<...>, Map<Id,...>)
Service implCreates UoW, calls commitWork() exactly once per public method
UoW type listParents before children in Application.UnitOfWork
Platform eventsRegistered with registerPublishAfterSuccessTransaction unless failure telemetry is wanted
Entry points@AuraEnabled / @InvocableMethod contain no business logic

References

  • references/domain-layer.md - dispatch table, every override, Configuration flags, TriggerEvent toggles, stateful domains, Test.Database mock 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_ISObjectUnitOfWork API table, relationship registration, platform events, custom IDML, 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:

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.