sf-fflib-foundations

Covers the fflib Apex Enterprise Patterns stack (fflib-apex-common, fflib-apex-mocks) - the four-layer Service/Domain/Selector/Unit of Work separation, the Application factory class with its SObject-t

SKILL.md

GitHub

fflib Foundations: Apex Enterprise Patterns

Source of truth for every signature below: apex-enterprise-patterns/fflib-apex-common @ master commit dab5977 (sfdx-source/apex-common/main/classes/*.cls), fflib-apex-mocks @ master commit d81e9e1, fflib-apex-common-samplecode @ master commit 4657d63. Anything not read from those files this session is marked [unverified].

When to use

Situation Use this skill
Adding fflib to a repo for the first time Yes - references/install-and-layout.md
Writing or fixing Application.cls Yes - references/application-factory.md
Choosing fflib vs trigger handler + service class Yes - references/fflib-vs-plain-apex.md
Looking up what an fflib_* class does Yes - references/class-catalogue.md
Writing a selector or a query Skill sf-fflib-selector-layer
Writing a domain, service, or Unit of Work Skill sf-fflib-domain-service-uow
Mocking, stubbing, verifying with ApexMocks Skill sf-fflib-testing
Trigger wiring, batch entry points, day-2 operations Skill sf-fflib-operations
Plain Apex idioms, no fflib in the repo Skill sf-apex-development

Decision table: does fflib earn its keep here?

Signal fflib Plain trigger handler + service classes
SObjects with non-trivial write logic > 5 <= 5
Apex classes in the package > 40 < 40
Multiple entry points per SObject (trigger, LWC, REST, batch, Flow invocable) Yes One or two
Unit tests must run without DML (Set<Id> + stubs) Required Nice to have
Second-generation packaging with a shared base library Yes No
Team has used Apex Enterprise Patterns before Yes No, and no budget to learn
Automation mostly Flow / Record-Triggered Flow No Yes - see skill sf-flow-automation
Org is a single-admin org with < 10 Apex classes No Yes

Two or more "plain" answers means do not adopt fflib. The library costs roughly 3 extra files per SObject (interface, implementation, mock registration) and a permanent onboarding tax. See references/fflib-vs-plain-apex.md for the same requirement implemented both ways.

Core patterns

1. The four layers and the dependency rules

Entry points        triggers, LWC @AuraEnabled controllers, REST resources,
                    Batchable/Queueable, InvocableMethod
      |  (only calls)
Service layer       coarse-grained, bulkified, transaction boundary, owns the UnitOfWork
      |
Domain layer        record-oriented behaviour + validation for one SObjectType
      |
Selector layer      all SOQL
      |
Unit of Work        all DML, ordered by SObjectType dependency

Enforceable rules, in priority order:

Rule Why
Only the Service layer calls commitWork() one transaction boundary per use case
Selectors never call services or domains they would become untestable and recursive
Domains never issue SOQL or DML directly they get records in, register work out
Entry points contain no business logic, only marshalling lets Flow/LWC/REST share behaviour
Every layer is reached through Application.*, never new Impl() in product code mock injection
Service methods take and return bulk shapes (Set<Id>, List<SObject>) see skill sf-governor-limits

2. Application.cls is the only wiring file

One class per package, four static finals. Verified shape from fflib-apex-common-samplecode/.../main/classes/Application.cls:

public class Application {
    // SObjectTypes in dependency order: parents before children.
    public static final fflib_Application.UnitOfWorkFactory UnitOfWork =
        new fflib_Application.UnitOfWorkFactory(
            new List<SObjectType> {
                Account.SObjectType,
                Opportunity.SObjectType,
                OpportunityLineItem.SObjectType,
                Invoice__c.SObjectType,
                InvoiceLine__c.SObjectType });

    // Interface -> implementing class.
    public static final fflib_Application.ServiceFactory Service =
        new fflib_Application.ServiceFactory(
            new Map<Type, Type> {
                IAccountsService.class => AccountsServiceImpl.class,
                IOpportunitiesService.class => OpportunitiesServiceImpl.class });

    // SObjectType -> selector class.
    public static final fflib_Application.SelectorFactory Selector =
        new fflib_Application.SelectorFactory(
            new Map<SObjectType, Type> {
                Account.SObjectType => AccountsSelector.class,
                Opportunity.SObjectType => OpportunitiesSelector.class });

    // SObjectType -> domain constructor class.
    public static final fflib_Application.DomainFactory Domain =
        new fflib_Application.DomainFactory(
            Application.Selector,
            new Map<SObjectType, Type> {
                Account.SObjectType => Accounts.Constructor.class,
                Opportunity.SObjectType => Opportunities.Constructor.class });
}

newInstance resolves the registered Type and calls Type.newInstance(), so every registered class needs a public zero-argument constructor. Unregistered lookups throw fflib_Application.DeveloperException with No implementation registered for service interface X or Selector class not found for SObjectType X (fflib_Application.cls). Full binding tables, the UserModeDML factory subclass and the common wiring mistakes are in references/application-factory.md.

3. Naming and file layout

The samplecode conventions, verified against its class paths:

Layer Interface Implementation Caller writes
Service IAccountsService AccountsServiceImpl AccountsService.updateOpportunityActivity(ids) (static facade)
Domain IAccounts Accounts (+ inner Constructor) (IAccounts) Application.Domain.newInstance(ids)
Selector IAccountsSelector extends fflib_ISObjectSelector AccountsSelector AccountsSelector.newInstance()
Unit of Work fflib_ISObjectUnitOfWork fflib_SObjectUnitOfWork Application.UnitOfWork.newInstance()

The service layer is three files: a with sharing static facade (AccountsService), the interface (IAccountsService), and the implementation (AccountsServiceImpl). The facade exists so callers keep static call syntax while the factory still controls instantiation:

public with sharing class AccountsService {
    public static void updateOpportunityActivity(Set<Id> accountIds) {
        service().updateOpportunityActivity(accountIds);
    }
    private static IAccountsService service() {
        return (IAccountsService) Application.Service.newInstance(IAccountsService.class);
    }
}

Directory layout inside a package directory (samplecode uses exactly these folder names):

force-app/main/default/classes/
  Application.cls
  service/      AccountsService.cls  IAccountsService.cls  AccountsServiceImpl.cls
  domains/      Accounts.cls         IAccounts.cls
  selectors/    AccountsSelector.cls IAccountsSelector.cls
  triggerHandlers/ AccountsTriggerHandler.cls
  controllers/  batchjobs/  restapis/

4. Installation: vendor, submodule, or deploy

fflib-apex-common @ master publishes no unlocked-package version id and no GitHub release; the repo has zero releases and its README advertises only a GitHub-deploy button. Any 04t... install id for these libraries is [unverified] - do not put one in a script.

# Option A (recommended): vendor into a dedicated package directory, pinned to a commit.
git clone https://github.com/apex-enterprise-patterns/fflib-apex-mocks.git /tmp/fflib-mocks
git -C /tmp/fflib-mocks checkout d81e9e1
mkdir -p fflib/main/default
cp -R /tmp/fflib-mocks/sfdx-source/apex-mocks/main/classes fflib/main/default/classes
# then fflib-apex-common on top of the same directory

# Deploy order matters only if your own tests reference ApexMocks: mocks first, then common.
sf project deploy start --source-dir fflib --target-org vf-dev --wait 20

fflib-apex-common's current README states "Dependencies: None for deployment or for running this library's Apex tests"; its docs/changelog.md still carries the September 2014 note that ApexMocks "must be deployed to the org before deploying this library". The README reflects master - deploy mocks first anyway, it is free and removes the ambiguity. Submodule and upgrade procedures: references/install-and-layout.md. Packaging consequences: skills sf-project-structure and sf-packaging-release.

5. Mock injection points

setMock on every factory is protected and @TestVisible, so it compiles only from test context (fflib_Application.cls).

Call Argument type
Application.UnitOfWork.setMock(uowMock) fflib_ISObjectUnitOfWork
Application.Service.setMock(IAccountsService.class, serviceMock) Type, Object
Application.Selector.setMock(selectorMock) fflib_ISObjectSelector - requires sObjectType() stubbed
Application.Selector.setMock(Account.SObjectType, selectorMock) SObjectType, fflib_ISObjectSelector
Application.Domain.setMock(domainMock) fflib_ISObjectDomain (uses sObjectType()) or fflib_IDomain (uses getType())
Application.Domain.setMock(Account.SObjectType, domainMock) SObjectType, fflib_ISObjectDomain

The single-argument selector and domain overloads call sObjectType() on the mock; if it is not stubbed the mock returns null and registration silently binds to null. fflib logs an INFO System.debug in that path. Prefer the two-argument overload. Detail: skill sf-fflib-testing.

6. Incremental migration from legacy Apex (strangler)

Step Action Old code
1 Deploy fflib-apex-mocks + fflib-apex-common, add empty Application.cls untouched
2 Extract queries of the busiest SObject into XSelector, register it legacy class calls XSelector.newInstance().selectById(ids)
3 Introduce fflib_ISObjectUnitOfWork in the one method with the most DML statements DML removed from legacy class
4 Create IXService/XServiceImpl, move the legacy method body in verbatim legacy public method becomes a one-line delegator
5 Introduce the Domain class only once two callers need the same record-level rule -
6 Convert the trigger last, after Service and Selector exist -

Keep the legacy class as a thin delegator until every caller is migrated, then delete it in the same release; do not leave two ways to do the same thing. Never mix a legacy [SELECT ...] and a selector for the same SObject in one class.

7. Static analysis and performance characteristics

fflib's own source suppresses PMD rules (fflib_SObjectSelector.cls header suppresses PMD.CyclomaticComplexity, PMD.ExcessivePublicCount, PMD.ApexDoc and more), so the library deploys clean only if your analyzer run excludes vendored paths. In your own fflib code expect:

Rule Trigger Handling
ApexCRUDViolation Database.query(qf.toSOQL()) - PMD cannot see the WITH USER_MODE that toSOQL() emits configure readAuthMethodPattern for your selector base, or scope the rule out of selectors/
ApexSOQLInjection dynamic query string built by fflib_QueryFactory same; use Database.queryWithBinds for user input
ApexSharingViolations AccountsServiceImpl without a sharing keyword always declare with sharing or inherited sharing
ApexDoc, ApexUnitTestClassShouldHaveAsserts generated interfaces, Impl classes keep as advisory, not gating

Exclude the vendored fflib/ directory from the Code Analyzer target; see skill sf-code-analyzer-quality. Governor cost of the factories: each Type.newInstance() is a reflective instantiation (no limit consumed, small CPU cost); fflib_SObjectDescribe caches describes per transaction, so a selector's first getSObjectFieldList() pays describe cost once. The real risks are getSObjectFieldList() bloat (heap) and per-record selector calls (SOQL 100 limit) - skills sf-governor-limits and sf-soql-sosl-optimization.

Anti-patterns

new AccountsServiceImpl() in product code. Bypasses Application.Service, so setMock cannot intervene and every consumer test needs DML.

// Wrong
IAccountsService svc = new AccountsServiceImpl();
// Right
IAccountsService svc = (IAccountsService) Application.Service.newInstance(IAccountsService.class);

Unit of Work SObjectType list in the wrong order. fflib_SObjectUnitOfWork commits in the order given; children before parents produces REQUIRED_FIELD_MISSING on the lookup at commit time. List Account before Opportunity before OpportunityLineItem.

One Application per feature. Two factories means two registries and two mock registries; setMock on one is invisible to the other. One Application class per package directory.

Service method taking a single record. updateOne(Id accountId) guarantees a governor failure when a trigger calls it per record. Take Set<Id> / List<SObject>.

Domain class issuing SOQL. Makes the domain untestable without DML and hides query cost inside record loops. Query in the service, pass records down.

Registering the domain class instead of its constructor. Account.SObjectType => Accounts.class fails at runtime unless Accounts itself implements a constructor interface; the samplecode registers Accounts.Constructor.class. See references/application-factory.md.

Verification

# 1. Local gate: format, lint, analyzer, jest. No org needed.
node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" local --changed

# 2. Compile + run the fflib-facing tests in an org.
sf project deploy start --source-dir force-app --target-org vf-dev --wait 30
sf apex run test --target-org vf-dev --synchronous \
  --tests AccountsServiceTest --tests AccountsSelectorTest \
  --code-coverage --result-format human

# 3. Prove the factories resolve every registration (fails fast on a missing binding).
sf apex run --target-org vf-dev --file scripts/apex/assert-application-wiring.apex

# 4. Coverage + analyzer gates (apexOrgCoverageMin 85, apexClassCoverageMin 75, severity 3).
node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" apex --target-org vf-dev
node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" analyzer

scripts/apex/assert-application-wiring.apex (anonymous Apex, run after every Application.cls edit):

for (SObjectType t : new List<SObjectType>{ Account.SObjectType, Opportunity.SObjectType }) {
    fflib_ISObjectSelector s = Application.Selector.newInstance(t);
    System.assertEquals(t, s.sObjectType(), 'Selector bound to wrong SObjectType: ' + t);
}
System.assert(Application.Service.newInstance(IAccountsService.class) instanceof IAccountsService);
System.assertNotEquals(null, Application.UnitOfWork.newInstance());

References