sf-fflib-operations

Covers running fflib Apex Enterprise Patterns in a real delivery pipeline - trigger inventory and one-trigger-per-object dispatch, bypass and kill-switch design with custom permissions and custom meta

SKILL.md

GitHub

fflib Operations

Everything around the fflib write path: dispatch inventory, switches, async, bulk, observability, packaging, analysis and upgrades. The layer semantics themselves are in skill sf-fflib-domain-service-uow; the factories in skill sf-fflib-foundations.

Grounded in apex-enterprise-patterns/fflib-apex-common master @ dab59777bac37f6b8a3cc2871fcf5df8d37764b4, fflib-apex-common-samplecode master @ 4657d63, and fflib-apex-mocks master (raw file reads, see References).

When to use

Situation Use this skill
Auditing which objects have triggers and which are fflib-dispatched Yes
Adding an admin-visible bypass for a data load or an incident Yes
Running an fflib service from Batch, Queueable or Schedulable Yes
Packaging fflib as an unlocked package, or bumping the fflib version Yes
Tuning code-analyzer.yml because PMD flags every selector Yes
Writing a domain, service or Unit of Work No - skill sf-fflib-domain-service-uow
Writing selector queries No - skill sf-fflib-selector-layer
Writing the tests No - skill sf-fflib-testing

Quick reference

Concern Mechanism Where it lives
Trigger dispatch fflib_SObjectDomain.triggerHandler(X.class) One trigger per object
Per-user / per-integration bypass Custom permission read with FeatureManagement.checkPermission Checked in one BypassService
Admin-tunable, deployable toggle Custom metadata __mdt read with getInstance / getAll No SOQL cost
Implementation swap Application.Service binding chosen from custom metadata Factory construction
Async service run Queueable + System.Finalizer, or Batch with a selector QueryLocator references/async-and-bulk.md
Reliable event emission uow.registerPublishAfterSuccessTransaction(...) Service
Deployment ordering apex-mocks, then apex-common, then app code references/packaging-and-deployment.md

Core patterns

1. Trigger inventory and dispatch strategy

An fflib org has exactly one trigger per object, and that trigger does nothing but dispatch. Inventory the org before changing anything:

sf data query --target-org vf-dev --use-tooling-api \
  --query "SELECT Name, TableEnumOrId, Status, ApiVersion FROM ApexTrigger ORDER BY TableEnumOrId"
Finding Action
Two triggers on one object Merge into one; a second trigger re-enters the dispatcher with a different record set and breaks stateful domains
Trigger with inline logic Move the body into a domain override, leave the dispatch line
Legacy handler framework alongside fflib Migrate object by object; during the transition, the single trigger calls the legacy handler and fflib_SObjectDomain.triggerHandler, in that order, so ordering stays deterministic
Record-triggered Flow doing the same work Decide one owner per rule; Flows and Apex triggers interleave by order of execution, not by intent (skill sf-flow-automation)

2. Bypass and kill switches

fflib_SObjectDomain.getTriggerEvent(...) is transaction-scoped Apex state - useful inside a job, useless as an operational switch. Operational switches need a single, testable read point:

public with sharing class BypassService
{
    private static Map<String, Boolean> cache = new Map<String, Boolean>();

    public static Boolean isBypassed(SObjectType objectType)
    {
        return isBypassed('Trigger_' + objectType.getDescribe().getName());
    }

    public static Boolean isBypassed(String featureName)
    {
        if (cache.containsKey(featureName)) { return cache.get(featureName); }

        Bypass__mdt setting = Bypass__mdt.getInstance(featureName);   // no SOQL cost
        Boolean bypassed = setting != null
            && (setting.Disabled__c
                || (String.isNotBlank(setting.CustomPermission__c)
                    && FeatureManagement.checkPermission(setting.CustomPermission__c)));

        cache.put(featureName, bypassed);
        return bypassed;
    }

    @TestVisible
    private static void setBypass(String name, Boolean value) { cache.put(name, value); }
}
public override void onAfterUpdate(Map<Id, SObject> existingRecords)
{
    if (BypassService.isBypassed(Opportunity.SObjectType)) { return; }
    // ...
}

__mdt.getInstance(...) and getAll() read from the metadata cache and do not consume SOQL queries; only the first 255 characters of any field are returned (Custom metadata types methods). Full implementations with tests, plus the data-load bypass used with sf data import bulk (skill sf-data-management): references/bypass-and-feature-flags.md.

3. Feature flags that swap factory bindings

fflib makes a feature flag a binding change rather than an if scattered through the code:

public class Application
{
    public static final fflib_Application.ServiceFactory Service =
        new fflib_Application.ServiceFactory(serviceBindings());

    private static Map<Type, Type> serviceBindings()
    {
        Boolean useV2 = !BypassService.isBypassed('Feature_NewPricing');
        return new Map<Type, Type>{
            IProjectsService.class => ProjectsServiceImpl.class,
            IPricingService.class  => useV2 ? PricingServiceV2Impl.class
                                            : PricingServiceV1Impl.class
        };
    }
}

Both implementations stay deployed and tested; the switch is metadata. Rollback is a metadata deploy, not a code revert.

4. Async runners

Rules that do not change:

Rule Reason
The async class is a shell; it calls a service Business logic stays testable synchronously
One Unit of Work per chunk / per Queueable execution Each is its own transaction; a UoW cannot cross them
Never pass a UoW or a domain instance into a Queueable SObject references have no committed context in the next transaction
Idempotency key on every chunk Batch chunks can be retried by the platform
Finalizer for failure handling and retry Runs in its own transaction, so it can call out after the job did DML
public with sharing class ProjectSyncQueueable
    implements Queueable, Database.AllowsCallouts, System.Finalizer
{
    private final Set<Id> projectIds;
    private final String correlationId;

    public ProjectSyncQueueable(Set<Id> projectIds, String correlationId)
    {
        this.projectIds = projectIds;
        this.correlationId = correlationId;
    }

    public void execute(QueueableContext context)
    {
        System.attachFinalizer(this);          // exactly one finalizer per job
        ProjectSyncService.sync(projectIds, correlationId);
    }

    public void execute(FinalizerContext context)
    {
        if (context.getResult() == ParentJobResult.SUCCESS) { return; }
        LoggingService.recordAsyncFailure(
            correlationId, context.getAsyncApexJobId(), context.getException());
    }
}

System.attachFinalizer accepts one finalizer per Queueable execution; the finalizer runs in a separate Apex and database transaction, and a failing job can be re-enqueued from a finalizer at most five consecutive times (Transaction finalizers). Batch, chunking and idempotency keys: references/async-and-bulk.md and skill sf-async-apex-patterns.

5. Platform events and integration flows

Publish through the Unit of Work so emission is tied to commit outcome, and subscribe with an ordinary fflib domain over the __e records:

trigger ProjectEvents on ProjectEvent__e (after insert)
{
    fflib_SObjectDomain.triggerHandler(ProjectEvents.class);
}
public inherited sharing class ProjectEvents extends fflib_SObjectDomain
{
    public ProjectEvents(List<ProjectEvent__e> records)
    {
        super(records, ProjectEvent__e.SObjectType);
        // The Automated Process user has no CRUD on the related objects
        Configuration.disableTriggerCRUDSecurity();
    }

    public override void onAfterInsert()
    {
        Set<Id> opportunityIds = new Set<Id>();
        for (ProjectEvent__e event : (List<ProjectEvent__e>) Records)
        {
            if (event.Action__c == 'ProjectCreated')
            {
                opportunityIds.add((Id) event.Opportunity__c);   // event field is Text
            }
        }
        if (opportunityIds.isEmpty()) { return; }
        DeliveryHandoffService.handOff(opportunityIds);
    }

    public class Constructor implements fflib_SObjectDomain.IConstructable
    {
        public fflib_SObjectDomain construct(List<SObject> records)
        {
            return new ProjectEvents((List<ProjectEvent__e>) records);
        }
    }
}

Use the two-argument super(records, SObjectType) constructor: an empty __e batch cannot infer its type. Replay, retry and high-volume subscriber concerns: skill sf-integration-patterns.

6. Callouts and commitWork()

Apex forbids a callout after uncommitted DML in the same transaction. commitWork() is DML, so:

Order Verdict
Callout, then register, then commitWork() Correct
commitWork(), then callout in the same transaction Throws You have uncommitted work pending
commitWork(), then callout from a Queueable or a Finalizer Correct - separate transaction
Callout inside an IDoWork registered with registerWork Wrong - doWork() runs inside the commit, after DML
public void pushToDelivery(Set<Id> projectIds)
{
    DeliveryResponse response = DeliveryGateway.post(buildPayload(projectIds));   // callout first

    fflib_ISObjectUnitOfWork uow = Application.UnitOfWork.newInstance();
    for (Project__c project : ProjectsSelector.newInstance().selectSObjectsById(projectIds))
    {
        project.ExternalId__c = response.idByProject.get(project.Id);
        uow.registerDirty(project, new List<SObjectField>{ Project__c.ExternalId__c });
    }
    uow.commitWork();                                                            // DML last
}

Mocking the gateway: skill sf-fflib-testing; named credentials and retry: skill sf-integration-patterns.

7. Large data volumes

Cost Measurement Mitigation
Domain instantiation per chunk fflib_SObjectDomain copies the record list and describes the SObject once per construction One domain instance per chunk, never per record
Selector field list breadth Heap grows with fields x rows Narrow selector methods for bulk jobs (skill sf-fflib-selector-layer)
UoW DML statements 1 savepoint + 1 per non-empty type per operation One commitWork() per chunk
Relationship resolution Iterates registered relationships per type Register parents and children in one pass
Trigger re-entry during commit Each UoW insert fires the target object's trigger Bypass the sub-object's domain for the job when the job already owns that logic

Batch scope for fflib work starts at 200 and comes down only with evidence; profile with a debug log before changing it (skill sf-debugging-logs). Limit arithmetic: skill sf-governor-limits; query shape: skill sf-soql-sosl-optimization.

Bypassing the framework for a bulk job is legitimate when the job is a pure data movement with no business rules - document the decision in the job class header and keep the bypass narrow.

8. Observability

fflib_ISObjectUnitOfWork uow = Application.UnitOfWork.newInstance();
String correlationId = LoggingService.newCorrelationId();

uow.registerNew(new AppLog__c(
    CorrelationId__c = correlationId,
    Source__c = 'ProjectsServiceImpl.createFromOpportunities',
    Level__c = 'INFO',
    Message__c = 'Creating ' + toCreate.size() + ' projects',
    CpuTimeMs__c = Limits.getCpuTime()));

A log row registered on the business UoW disappears with the rollback. When the log must survive a failure, use a second UoW committed in the catch, or registerPublishAfterFailureTransaction on a platform event whose subscriber writes the log. The correlation id is threaded through service, Queueable, finalizer and event payload so one incident is one query. Trace flags and log levels: skill sf-debugging-logs.

9. Deployment, packaging and analysis

Dependency order is fixed: fflib-apex-mocks, then fflib-apex-common, then application code. Vendored fflib test classes inflate the denominator of the org coverage gate - either exclude them from the package directory or account for them in the gate. Package layout, sfdx-project.json dependencies, test levels and coverage handling: references/packaging-and-deployment.md, plus skills sf-packaging-release and sf-deployment-strategies.

Code Analyzer misfires predictably on fflib: ApexCRUDViolation on selectors that enforce access through fflib_SObjectSelector, ApexFlsViolationRule (Graph Engine) on writes routed through a custom IDML, ApexSharingViolations on inherited sharing domains, cyclomatic complexity on the dispatcher. Scope the suppression to the framework path, never the whole workspace - table and YAML in references/review-and-upgrade.md and skill sf-code-analyzer-quality.

10. Wave model

Wave Agent fflib responsibility Gate
0 sf-scout Trigger inventory, existing domains/selectors/services, fflib version -
1 sf-apex-engineer Domain, service, selector, UoW wiring, Application registration -
2 sf-test-engineer Mocked layer tests plus one DML integration test per object vf-check local
2 sf-quality-gate code-analyzer.yml suppressions justified, no new debt vf-check analyzer
2 sf-security-reviewer UserModeDML, every disableTriggerCRUDSecurity(), sharing declarations vf-check analyzer
3 sf-deploy-engineer Dependency-ordered deploy, validate then quick deploy vf-check deploy-validate
4 sf-org-verifier Trigger fires, UoW commits, events published vf-check smoke

Orchestration details: skill sf-workflow-orchestration; smoke probe design: skill sf-post-deploy-verification.

Anti-patterns

A bypass read per record.

// WRONG - metadata lookup and describe repeated per record
for (Opportunity opp : (List<Opportunity>) Records)
{
    if (BypassService.isBypassed(Opportunity.SObjectType)) { continue; }
}

// RIGHT - one check, early return
if (BypassService.isBypassed(Opportunity.SObjectType)) { return; }
for (Opportunity opp : (List<Opportunity>) Records) { /* ... */ }

A global "disable all triggers" checkbox. It hides the blast radius and gets left on. Scope switches per object and per feature, and log every activation.

commitWork() inside a Batch execute loop. One commitWork() per execute, outside any loop.

Callout inside IDoWork. doWork() runs after DML inside commitWork(); the callout throws.

Upgrading fflib by copying the new classes over the old ones. Diff first; fflib_SObjectDomain and fflib_SObjectUnitOfWork have changed defaults between versions. Runbook: references/review-and-upgrade.md.

Verification

# Static gate, including the fflib-specific analyzer configuration
node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" analyzer --changed
node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" local --changed

# Org tests and coverage gates
node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" apex --target-org vf-int

# Dependency-ordered validation, then quick deploy
node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" deploy-validate --target-org vf-int
node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" deploy-quick --target-org vf-int

# Post-deploy verification
node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" smoke --target-org vf-int

# Trigger inventory: exactly one trigger per object
sf data query --target-org vf-int --use-tooling-api --json \
  --query "SELECT TableEnumOrId, COUNT(Id) triggers FROM ApexTrigger GROUP BY TableEnumOrId" \
  | jq -r '.result.records[] | select(.triggers > 1) | .TableEnumOrId'

References

Sibling skills: sf-fflib-foundations, sf-fflib-selector-layer, sf-fflib-domain-service-uow, sf-fflib-testing, sf-async-apex-patterns, sf-governor-limits, sf-soql-sosl-optimization, sf-integration-patterns, sf-flow-automation, sf-data-management, sf-debugging-logs, sf-code-analyzer-quality, sf-deployment-strategies, sf-packaging-release, sf-cli-operations, sf-project-structure, sf-scratch-orgs-sandboxes, sf-workflow-orchestration, sf-post-deploy-verification, sf-security-model.

Official docs used: Transaction finalizers, Custom metadata types methods, Publish platform events with Apex, Execution governors and limits, Code Analyzer suppressions.