agentleFS
grzmolgrzmol/vibe-force/skills/sf-fflib-operations/SKILL.md

sf-fflib-operations

Skill2 starsChanged 2 months agoMITView on GitHub

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 metadata, feature flags that swap Application factory bindings, Queueable with Finalizer and Batch runners that call services with per-chunk Unit of Work commits, platform-event publishing and subscriber domains, callout ordering around commitWork, large-data-volume tuning, structured logging and correlation ids, packaging and deployment ordering for fflib-apex-mocks and fflib-apex-common, Code Analyzer rules that misfire on fflib, PR review checklists and the fflib version-upgrade runbook. Use this skill when auditing triggers in an fflib org, adding a bypass switch, running fflib code from Batch or Queueable, packaging or upgrading fflib, tuning Code Analyzer for fflib, or reviewing an fflib pull request.

The file

We split this skill into the rules it gives an agent

1 rule. About 0 are common, 0 are distinctive, and 1 is very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 1

    Very specific

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

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Use the two-argument super(records, SObjectType) constructor: an empty __e batch cannot infer its type.

    line 226

≈ “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 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

SituationUse this skill
Auditing which objects have triggers and which are fflib-dispatchedYes
Adding an admin-visible bypass for a data load or an incidentYes
Running an fflib service from Batch, Queueable or SchedulableYes
Packaging fflib as an unlocked package, or bumping the fflib versionYes
Tuning code-analyzer.yml because PMD flags every selectorYes
Writing a domain, service or Unit of WorkNo - skill sf-fflib-domain-service-uow
Writing selector queriesNo - skill sf-fflib-selector-layer
Writing the testsNo - skill sf-fflib-testing

Quick reference

ConcernMechanismWhere it lives
Trigger dispatchfflib_SObjectDomain.triggerHandler(X.class)One trigger per object
Per-user / per-integration bypassCustom permission read with FeatureManagement.checkPermissionChecked in one BypassService
Admin-tunable, deployable toggleCustom metadata __mdt read with getInstance / getAllNo SOQL cost
Implementation swapApplication.Service binding chosen from custom metadataFactory construction
Async service runQueueable + System.Finalizer, or Batch with a selector QueryLocatorreferences/async-and-bulk.md
Reliable event emissionuow.registerPublishAfterSuccessTransaction(...)Service
Deployment orderingapex-mocks, then apex-common, then app codereferences/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"
FindingAction
Two triggers on one objectMerge into one; a second trigger re-enters the dispatcher with a different record set and breaks stateful domains
Trigger with inline logicMove the body into a domain override, leave the dispatch line
Legacy handler framework alongside fflibMigrate 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 workDecide 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:

RuleReason
The async class is a shell; it calls a serviceBusiness logic stays testable synchronously
One Unit of Work per chunk / per Queueable executionEach is its own transaction; a UoW cannot cross them
Never pass a UoW or a domain instance into a QueueableSObject references have no committed context in the next transaction
Idempotency key on every chunkBatch chunks can be retried by the platform
Finalizer for failure handling and retryRuns 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:

OrderVerdict
Callout, then register, then commitWork()Correct
commitWork(), then callout in the same transactionThrows You have uncommitted work pending
commitWork(), then callout from a Queueable or a FinalizerCorrect - separate transaction
Callout inside an IDoWork registered with registerWorkWrong - 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

CostMeasurementMitigation
Domain instantiation per chunkfflib_SObjectDomain copies the record list and describes the SObject once per constructionOne domain instance per chunk, never per record
Selector field list breadthHeap grows with fields x rowsNarrow selector methods for bulk jobs (skill sf-fflib-selector-layer)
UoW DML statements1 savepoint + 1 per non-empty type per operationOne commitWork() per chunk
Relationship resolutionIterates registered relationships per typeRegister parents and children in one pass
Trigger re-entry during commitEach UoW insert fires the target object's triggerBypass 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

WaveAgentfflib responsibilityGate
0sf-scoutTrigger inventory, existing domains/selectors/services, fflib version-
1sf-apex-engineerDomain, service, selector, UoW wiring, Application registration-
2sf-test-engineerMocked layer tests plus one DML integration test per objectvf-check local
2sf-quality-gatecode-analyzer.yml suppressions justified, no new debtvf-check analyzer
2sf-security-reviewerUserModeDML, every disableTriggerCRUDSecurity(), sharing declarationsvf-check analyzer
3sf-deploy-engineerDependency-ordered deploy, validate then quick deployvf-check deploy-validate
4sf-org-verifierTrigger fires, UoW commits, events publishedvf-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

  • references/bypass-and-feature-flags.md - custom metadata and custom permission implementations with tests, data-load bypass, feature-flag binding swaps.
  • references/async-and-bulk.md - Queueable plus Finalizer service runner, Batch with a selector QueryLocator, per-chunk Unit of Work, idempotency keys, LDV profiling.
  • references/packaging-and-deployment.md - package layout, dependency ordering, deploy commands, test levels, coverage handling.
  • references/review-and-upgrade.md - PR checklist, fflib upgrade runbook, misfiring analyzer rules and scoped suppressions.

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.

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.