Salesforce · · 30 min read

The Apex Common Library (fflib)

A practical guide to the Apex Common Library — installing fflib, implementing the selector layer, domain layer, and service layer with the enterprise patterns framework.

Part 64: The Apex Common Library (fflib)

Welcome back to the Salesforce series. Throughout this series we have explored Apex from basic syntax to advanced asynchronous patterns, testing strategies, and integration techniques. Now we are going to look at one of the most important open-source libraries in the Salesforce ecosystem — the Apex Common Library, commonly known as fflib. This library provides the foundation classes and utilities that make Andrew Fawcett’s enterprise patterns practical to implement in real projects.

This is Part 64 of the series. We will cover what fflib is, where it came from, how to install it, and how to implement each of its three core layers — selectors, domain, and service. We will close with a complete hands-on project that ties all three layers together in a real scenario where behavior changes depending on the running user. If you have been writing Apex without a framework, this post will show you what structured enterprise development looks like.


What Is the Apex Common Library?

The Apex Common Library is an open-source framework that provides base classes, utilities, and an Application factory for building well-structured Apex applications on the Salesforce platform. It is the implementation backbone behind the Separation of Concerns architecture that Andrew Fawcett described in his book “Salesforce Lightning Platform Enterprise Architecture” and presented at dozens of Dreamforce sessions.

The History — FinancialForce and fflib

The library originated at FinancialForce.com, a company that built large-scale enterprise applications on the Salesforce platform. Their engineering team, led by Andrew Fawcett, needed a way to manage complexity in massive Apex codebases that spanned hundreds of classes and thousands of lines of code. They developed a set of base classes and patterns to enforce separation of concerns, and they open-sourced the result.

The “ff” in fflib stands for FinancialForce. The library lives on GitHub under the name apex-common (sometimes called fflib-apex-common), and it has a companion library called apex-mocks (or fflib-apex-mocks) that provides a mocking framework for unit testing. Together, these two libraries form the most widely adopted enterprise framework in the Salesforce ecosystem.

What the Library Provides

At its core, fflib gives you:

  • fflib_Application — A factory class that acts as the central registry for your selectors, domain classes, and services. It uses maps of SObjectType-to-class bindings so you can swap implementations at runtime (critical for testing and extensibility).
  • fflib_SObjectSelector — A base class for the Selector layer. It standardizes how you query data, enforces field-level security, provides a QueryFactory for building dynamic queries, and ensures consistency across all your SOQL.
  • fflib_SObjectDomain — A base class for the Domain layer. It provides the trigger handler framework, virtual methods for each trigger event (onBeforeInsert, onAfterUpdate, etc.), and a structured place for validation and business logic that operates on a set of records.
  • fflib_SObjectUnitOfWork — A class that implements the Unit of Work pattern. It lets you register records for insert, update, delete, and relationship binding, then commit everything in a single transaction with proper ordering.
  • fflib_SecurityUtils — Utilities for enforcing CRUD and FLS (field-level security) checks.
  • fflib_QueryFactory — A builder-pattern class for constructing SOQL queries programmatically, including subselects, ordering, field sets, and conditions.

Relationship to Enterprise Patterns

The enterprise patterns (Selector, Domain, Service, Unit of Work) are the architecture. The Apex Common Library is the implementation. You could implement the patterns from scratch with your own base classes, but fflib gives you battle-tested abstractions that thousands of teams have used in production. Think of it this way: the patterns are the blueprint, and fflib is the prefabricated foundation you build on.

The architecture follows a strict layering:

┌─────────────────────────────────────┐
│         Controllers / API / Flows    │  ← Entry points
├─────────────────────────────────────┤
│            Service Layer             │  ← Orchestration, transaction boundary
├─────────────────────────────────────┤
│            Domain Layer              │  ← Business logic, validation, trigger handling
├─────────────────────────────────────┤
│           Selector Layer             │  ← All SOQL queries
├─────────────────────────────────────┤
│        Unit of Work                  │  ← DML orchestration
├─────────────────────────────────────┤
│       Salesforce Platform (SObjects) │
└─────────────────────────────────────┘

Each layer only calls downward. Controllers call services. Services call domain and selectors. Domain classes call selectors. Nobody reaches across layers or bypasses the chain. This discipline is what keeps large codebases maintainable.


How to Install the Apex Common Library

There are several ways to get fflib into your org. We will cover the most common approaches.

GitHub Repositories

The source code lives in two repositories:

  • apex-common (fflib-apex-common): https://github.com/apex-enterprise-patterns/fflib-apex-common
  • apex-mocks (fflib-apex-mocks): https://github.com/apex-enterprise-patterns/fflib-apex-mocks

You need both. The apex-common library depends on apex-mocks because the Application factory uses the mocking interfaces for test isolation.

Installing via SFDX (Unlocked Packages)

The easiest way to install is through the unlocked packages that the maintainers publish. Run these commands in your terminal:

# Install apex-mocks first (dependency)
sf package install --package 04t6F000001ZicYQAS --wait 10 --target-org your-org-alias

# Install apex-common
sf package install --package 04t6F000001ZjjUQAS --wait 10 --target-org your-org-alias

The package IDs above are examples — always check the repository README for the latest package version IDs, as they update with new releases.

Installing via Source Deploy

If you prefer to work with source, clone both repositories and deploy:

# Clone the repositories
git clone https://github.com/apex-enterprise-patterns/fflib-apex-mocks.git
git clone https://github.com/apex-enterprise-patterns/fflib-apex-common.git

# Deploy apex-mocks first
cd fflib-apex-mocks
sf project deploy start --source-dir sfdx-src --target-org your-org-alias

# Deploy apex-common
cd ../fflib-apex-common
sf project deploy start --source-dir sfdx-src --target-org your-org-alias

Setting Up the Application Class

Once fflib is in your org, the first thing you build is your Application class. This is the central registry that maps SObject types to their selector, domain, and service implementations. Every fflib project has one of these:

public class Application {

    // Selector layer bindings
    public static final fflib_Application.SelectorFactory Selector =
        new fflib_Application.SelectorFactory(
            new Map<SObjectType, Type>{
                Account.SObjectType => AccountsSelector.class,
                Contact.SObjectType => ContactsSelector.class,
                Opportunity.SObjectType => OpportunitiesSelector.class,
                Case.SObjectType => CasesSelector.class
            }
        );

    // Domain layer bindings
    public static final fflib_Application.DomainFactory Domain =
        new fflib_Application.DomainFactory(
            Application.Selector,
            new Map<SObjectType, Type>{
                Account.SObjectType => AccountsDomain.Constructor.class,
                Contact.SObjectType => ContactsDomain.Constructor.class,
                Opportunity.SObjectType => OpportunitiesDomain.Constructor.class,
                Case.SObjectType => CasesDomain.Constructor.class
            }
        );

    // Service layer bindings
    public static final fflib_Application.ServiceFactory Service =
        new fflib_Application.ServiceFactory(
            new Map<Type, Type>{
                IAccountService.class => AccountServiceImpl.class,
                IOpportunityService.class => OpportunityServiceImpl.class,
                ICaseService.class => CaseServiceImpl.class
            }
        );

    // Unit of Work factory
    public static final fflib_Application.UnitOfWorkFactory UnitOfWork =
        new fflib_Application.UnitOfWorkFactory(
            new List<SObjectType>{
                Account.SObjectType,
                Contact.SObjectType,
                Opportunity.SObjectType,
                OpportunityLineItem.SObjectType,
                Case.SObjectType
            }
        );
}

A few things to notice:

  • SelectorFactory maps each SObjectType to a selector class. When any code in your app calls Application.Selector.newInstance(Account.SObjectType), it gets back an AccountsSelector.
  • DomainFactory maps each SObjectType to a domain constructor class. It also takes a reference to the SelectorFactory so domain classes can query data when needed.
  • ServiceFactory maps interfaces to implementations. This is the key to substituting mock services in tests.
  • UnitOfWorkFactory takes an ordered list of SObjectTypes. The order matters — it determines the DML commit sequence. Parent objects (Account) must come before children (Contact, Opportunity) so that relationship IDs resolve correctly.

This Application class is the spine of your entire project. Every new object you add to the system gets registered here.


How to Implement the Selector Layer

The Selector layer is responsible for all SOQL queries in your application. Instead of scattering queries throughout your codebase, you centralize them in selector classes. This gives you a single place to manage field lists, enforce security, and optimize queries.

Extending fflib_SObjectSelector

Every selector class extends fflib_SObjectSelector and overrides a few required methods:

public class AccountsSelector extends fflib_SObjectSelector {

    public Schema.SObjectType getSObjectType() {
        return Account.SObjectType;
    }

    public List<Schema.SObjectField> getSObjectFieldList() {
        return new List<Schema.SObjectField>{
            Account.Id,
            Account.Name,
            Account.Industry,
            Account.AnnualRevenue,
            Account.NumberOfEmployees,
            Account.BillingCity,
            Account.BillingState,
            Account.BillingCountry,
            Account.OwnerId,
            Account.Type,
            Account.Rating,
            Account.CreatedDate
        };
    }

    public List<Schema.SObjectField> getOrderBy() {
        return new List<Schema.SObjectField>{
            Account.Name
        };
    }
}

Let us break down each method:

  • getSObjectType() — Returns the SObjectType token for the object this selector queries. The base class uses this to build the FROM clause.
  • getSObjectFieldList() — Returns the default list of fields that every query from this selector will include. This is your “standard field set” — the fields that most consumers of Account data need. You define it once, and every query method in this selector automatically includes these fields.
  • getOrderBy() — Returns the default ORDER BY fields. If you do not override this, results come back in an undefined order.

The Built-In selectById Method

The base class gives you selectById for free. It queries records by a set of IDs using the fields from getSObjectFieldList():

// Anywhere in your code
AccountsSelector selector = (AccountsSelector) Application.Selector.newInstance(Account.SObjectType);
List<Account> accounts = selector.selectById(new Set<Id>{ accountId1, accountId2 });

Or more concisely using the static convenience method:

List<Account> accounts = (List<Account>) AccountsSelector.newInstance().selectById(accountIds);

Custom Query Methods

The real power comes from adding your own query methods. Each method uses the QueryFactory to build queries that always include the standard field list:

public class AccountsSelector extends fflib_SObjectSelector {

    // ... getSObjectType, getSObjectFieldList, getOrderBy as above ...

    // Convenience static factory method
    public static AccountsSelector newInstance() {
        return (AccountsSelector) Application.Selector.newInstance(Account.SObjectType);
    }

    // Custom: select accounts by industry
    public List<Account> selectByIndustry(Set<String> industries) {
        return (List<Account>) Database.query(
            newQueryFactory()
                .setCondition('Industry IN :industries')
                .toSOQL()
        );
    }

    // Custom: select accounts with their contacts
    public List<Account> selectByIdWithContacts(Set<Id> accountIds) {
        fflib_QueryFactory accountQueryFactory = newQueryFactory();
        accountQueryFactory.setCondition('Id IN :accountIds');

        // Add a subselect for contacts
        fflib_QueryFactory contactSubQuery = accountQueryFactory.subselectQuery('Contacts');
        contactSubQuery.selectField('FirstName');
        contactSubQuery.selectField('LastName');
        contactSubQuery.selectField('Email');
        contactSubQuery.selectField('Phone');
        contactSubQuery.setOrdering('LastName', fflib_QueryFactory.SortOrder.ASCENDING);

        return (List<Account>) Database.query(accountQueryFactory.toSOQL());
    }

    // Custom: select high-value accounts
    public List<Account> selectHighValueAccounts(Decimal minimumRevenue) {
        return (List<Account>) Database.query(
            newQueryFactory()
                .setCondition('AnnualRevenue >= :minimumRevenue')
                .setOrdering('AnnualRevenue', fflib_QueryFactory.SortOrder.DESCENDING)
                .setLimit(100)
                .toSOQL()
        );
    }

    // Custom: select accounts with a specific rating
    public List<Account> selectByRating(String rating) {
        return (List<Account>) Database.query(
            newQueryFactory()
                .setCondition('Rating = :rating')
                .toSOQL()
        );
    }
}

Understanding the QueryFactory

The newQueryFactory() method (inherited from the base class) returns an fflib_QueryFactory pre-configured with:

  • The SObject type from getSObjectType()
  • All fields from getSObjectFieldList()
  • The default ordering from getOrderBy()
  • FLS enforcement (if enabled)

You then chain additional configuration:

fflib_QueryFactory qf = newQueryFactory();

// Add conditions
qf.setCondition('Industry = :industry AND AnnualRevenue > :minRevenue');

// Override ordering
qf.setOrdering('AnnualRevenue', fflib_QueryFactory.SortOrder.DESCENDING);

// Add a limit
qf.setLimit(50);

// Add additional fields beyond the standard set
qf.selectField('Website');
qf.selectField('Description');

// Generate the SOQL string
String query = qf.toSOQL();

Using Field Sets

If your org uses field sets to let admins control which fields appear in different contexts, selectors support them natively:

public List<Account> selectByIdWithFieldSet(Set<Id> accountIds, Schema.FieldSet fieldSet) {
    fflib_QueryFactory qf = newQueryFactory();
    qf.selectFieldSet(fieldSet);
    qf.setCondition('Id IN :accountIds');
    return (List<Account>) Database.query(qf.toSOQL());
}

This is powerful for managed packages where the subscriber org might add custom fields to a field set, and your query automatically picks them up without code changes.

Enforcing Security in the Selector

By default, fflib selectors enforce field-level security. If the running user does not have read access to a field in your getSObjectFieldList(), the query will throw an fflib_SecurityUtils.FlsException. You can control this behavior:

public class AccountsSelector extends fflib_SObjectSelector {

    // Constructor that disables FLS enforcement
    public AccountsSelector() {
        super(false); // false = do not enforce FLS
    }

    // Constructor to allow toggling
    public AccountsSelector(Boolean enforceFLS) {
        super(enforceFLS);
    }

    // ... rest of class
}

In most enterprise apps, you want FLS enforcement on by default and only disable it for system-level operations where you explicitly intend to bypass security (running as the system user in a batch job, for example).


How to Implement the Domain Layer

The Domain layer is where your business logic lives. Each SObject gets a domain class that encapsulates validation rules, default values, calculation logic, and trigger handling. If the Selector layer answers “how do I get data?”, the Domain layer answers “what are the rules for this data?”

Extending fflib_SObjectDomain

Here is the structure of a domain class:

public class AccountsDomain extends fflib_SObjectDomain {

    // The typed list of records this domain instance operates on
    private List<Account> accounts {
        get { return (List<Account>) Records; }
    }

    // Constructor — receives the list of records
    public AccountsDomain(List<Account> records) {
        super(records);
    }

    // Inner constructor class — required by the Application factory
    public class Constructor implements fflib_SObjectDomain.IConstructable {
        public fflib_SObjectDomain construct(List<SObject> records) {
            return new AccountsDomain((List<Account>) records);
        }
    }
}

The constructor pattern deserves explanation. The Application.Domain factory needs a way to instantiate your domain class, but it only knows about List<SObject> at compile time. The inner Constructor class implements IConstructable, which is the bridge. When the factory calls construct(), it passes the generic list, and your constructor casts it to the typed list.

Overriding Trigger Event Methods

The domain class gives you virtual methods for every trigger event. Override the ones you need:

public class AccountsDomain extends fflib_SObjectDomain {

    private List<Account> accounts {
        get { return (List<Account>) Records; }
    }

    public AccountsDomain(List<Account> records) {
        super(records);
    }

    // ── Before Insert ──
    public override void onBeforeInsert() {
        for (Account acc : accounts) {
            // Set defaults
            if (acc.Rating == null) {
                acc.Rating = 'Warm';
            }
            // Normalize data
            if (acc.BillingCountry != null) {
                acc.BillingCountry = acc.BillingCountry.toUpperCase();
            }
        }
    }

    // ── Before Update ──
    public override void onBeforeUpdate(Map<Id, SObject> existingRecords) {
        Map<Id, Account> oldMap = (Map<Id, Account>) existingRecords;

        for (Account acc : accounts) {
            Account oldAcc = oldMap.get(acc.Id);

            // If revenue crossed the $1M threshold, upgrade the type
            if (acc.AnnualRevenue >= 1000000 && oldAcc.AnnualRevenue < 1000000) {
                acc.Type = 'Customer - Channel';
                acc.Rating = 'Hot';
            }
        }
    }

    // ── After Insert ──
    public override void onAfterInsert() {
        List<Task> followUpTasks = new List<Task>();

        for (Account acc : accounts) {
            if (acc.Type == 'Prospect') {
                followUpTasks.add(new Task(
                    WhatId = acc.Id,
                    OwnerId = acc.OwnerId,
                    Subject = 'Initial outreach for ' + acc.Name,
                    ActivityDate = Date.today().addDays(3),
                    Priority = 'High'
                ));
            }
        }

        if (!followUpTasks.isEmpty()) {
            insert followUpTasks;
        }
    }

    // ── After Update ──
    public override void onAfterUpdate(Map<Id, SObject> existingRecords) {
        Map<Id, Account> oldMap = (Map<Id, Account>) existingRecords;
        Set<Id> ownerChangedIds = new Set<Id>();

        for (Account acc : accounts) {
            Account oldAcc = oldMap.get(acc.Id);
            if (acc.OwnerId != oldAcc.OwnerId) {
                ownerChangedIds.add(acc.Id);
            }
        }

        if (!ownerChangedIds.isEmpty()) {
            reassignOpenActivities(ownerChangedIds);
        }
    }

    // ── Before Delete ──
    public override void onBeforeDelete() {
        for (Account acc : accounts) {
            if (acc.Type == 'Customer - Direct') {
                acc.addError('Cannot delete an active direct customer account.');
            }
        }
    }

    // Private helper
    private void reassignOpenActivities(Set<Id> accountIds) {
        List<Task> tasks = [
            SELECT Id, OwnerId, WhatId
            FROM Task
            WHERE WhatId IN :accountIds
            AND Status != 'Completed'
        ];

        Map<Id, Id> accountToNewOwner = new Map<Id, Id>();
        for (Account acc : accounts) {
            if (accountIds.contains(acc.Id)) {
                accountToNewOwner.put(acc.Id, acc.OwnerId);
            }
        }

        for (Task t : tasks) {
            t.OwnerId = accountToNewOwner.get(t.WhatId);
        }

        if (!tasks.isEmpty()) {
            update tasks;
        }
    }

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

Validation in the Domain

The base class provides an onValidate() method specifically for validation logic. Separating validation from the event methods keeps your code organized:

public override void onValidate() {
    for (Account acc : accounts) {
        if (String.isBlank(acc.Name)) {
            acc.Name.addError('Account Name is required.');
        }
        if (acc.AnnualRevenue != null && acc.AnnualRevenue < 0) {
            acc.AnnualRevenue.addError('Annual Revenue cannot be negative.');
        }
    }
}

// Validation that runs only on update — gets the old values
public override void onValidate(Map<Id, SObject> existingRecords) {
    Map<Id, Account> oldMap = (Map<Id, Account>) existingRecords;

    for (Account acc : accounts) {
        Account oldAcc = oldMap.get(acc.Id);

        // Prevent downgrading a Customer to a Prospect
        if (oldAcc.Type == 'Customer - Direct' && acc.Type == 'Prospect') {
            acc.Type.addError('Cannot downgrade a direct customer to a prospect.');
        }
    }
}

The onValidate() method with no parameters runs on insert. The overload with existingRecords runs on update.

The Trigger Handler Pattern

With fflib, your actual trigger file becomes a one-liner:

trigger AccountTrigger on Account (
    before insert, before update, before delete,
    after insert, after update, after delete, after undelete
) {
    fflib_SObjectDomain.triggerHandler(AccountsDomain.class);
}

That single line does everything. The triggerHandler static method:

  1. Reads Trigger.operationType to determine which event is firing.
  2. Uses the Constructor inner class to instantiate your domain with Trigger.new (or Trigger.old for delete).
  3. Calls the appropriate virtual method (onBeforeInsert, onAfterUpdate, etc.).
  4. Passes Trigger.oldMap as the existingRecords parameter where applicable.

You never write if (Trigger.isBefore && Trigger.isInsert) blocks again. The framework handles the routing.

Disabling Triggers in Tests

The domain class provides a mechanism to disable trigger logic, which is invaluable in tests where you need to set up data without firing all your business logic:

@IsTest
static void testSomething() {
    // Disable the AccountsDomain trigger handling
    fflib_SObjectDomain.getTriggerEvent(AccountsDomain.class).disableAll();

    // Insert test data without trigger logic firing
    Account testAccount = new Account(Name = 'Test');
    insert testAccount;

    // Re-enable
    fflib_SObjectDomain.getTriggerEvent(AccountsDomain.class).enableAll();

    // Now do the actual test...
}

How to Implement the Service Layer

The Service layer sits at the top of your business logic stack. It is the entry point that controllers, Lightning components, Flow invocable methods, REST APIs, and batch jobs call. The service layer orchestrates operations that may span multiple domain classes and selectors, and it owns the transaction boundary.

The Service Interface Pattern

Every service starts with an interface. This is critical for testability — you can mock the interface in tests without running the real implementation:

public interface IAccountService {

    void updateAccountRatings(Set<Id> accountIds);
    void mergeAccounts(Id masterAccountId, Set<Id> duplicateAccountIds);
    void assignAccountTerritory(Set<Id> accountIds, String territory);
    Id createAccountWithContacts(Account account, List<Contact> contacts);
}

Then the implementation:

public class AccountServiceImpl implements IAccountService {

    public void updateAccountRatings(Set<Id> accountIds) {
        // Create a Unit of Work to manage DML
        fflib_ISObjectUnitOfWork uow = Application.UnitOfWork.newInstance();

        // Use the selector to get current data
        List<Account> accounts = AccountsSelector.newInstance().selectById(accountIds);

        // Use the domain to apply business logic
        AccountsDomain domain = new AccountsDomain(accounts);
        domain.recalculateRatings();

        // Register changes with the Unit of Work
        for (Account acc : accounts) {
            uow.registerDirty(acc);
        }

        // Commit all DML in one transaction
        uow.commitWork();
    }

    public void mergeAccounts(Id masterAccountId, Set<Id> duplicateAccountIds) {
        fflib_ISObjectUnitOfWork uow = Application.UnitOfWork.newInstance();

        Set<Id> allIds = new Set<Id>(duplicateAccountIds);
        allIds.add(masterAccountId);

        List<Account> allAccounts = AccountsSelector.newInstance().selectByIdWithContacts(allIds);

        Account masterAccount;
        List<Account> duplicates = new List<Account>();

        for (Account acc : allAccounts) {
            if (acc.Id == masterAccountId) {
                masterAccount = acc;
            } else {
                duplicates.add(acc);
            }
        }

        // Reparent contacts from duplicates to master
        for (Account dup : duplicates) {
            if (dup.Contacts != null) {
                for (Contact con : dup.Contacts) {
                    con.AccountId = masterAccountId;
                    uow.registerDirty(con);
                }
            }
            uow.registerDeleted(dup);
        }

        uow.commitWork();
    }

    public void assignAccountTerritory(Set<Id> accountIds, String territory) {
        fflib_ISObjectUnitOfWork uow = Application.UnitOfWork.newInstance();

        List<Account> accounts = AccountsSelector.newInstance().selectById(accountIds);

        for (Account acc : accounts) {
            acc.BillingState = territory;
            uow.registerDirty(acc);
        }

        uow.commitWork();
    }

    public Id createAccountWithContacts(Account account, List<Contact> contacts) {
        fflib_ISObjectUnitOfWork uow = Application.UnitOfWork.newInstance();

        uow.registerNew(account);

        for (Contact con : contacts) {
            uow.registerNew(con, Contact.AccountId, account);
        }

        uow.commitWork();

        return account.Id;
    }
}

Using Application.Service.newInstance()

You never instantiate a service implementation directly. Always go through the factory:

// In a controller, invocable method, REST resource, or anywhere
IAccountService service = (IAccountService) Application.Service.newInstance(IAccountService.class);
service.updateAccountRatings(accountIds);

This indirection is the key to the entire pattern. In production, the factory returns AccountServiceImpl. In a test, you can substitute a mock:

@IsTest
static void testControllerCallsService() {
    // Create a mock service
    fflib_ApexMocks mocks = new fflib_ApexMocks();
    IAccountService mockService = (IAccountService) mocks.mock(IAccountService.class);

    // Tell the Application factory to use the mock
    Application.Service.setMock(IAccountService.class, mockService);

    // Now when the controller calls Application.Service.newInstance(IAccountService.class),
    // it gets the mock instead of the real implementation
    MyController controller = new MyController();
    controller.doSomething();

    // Verify the service was called with expected parameters
    ((IAccountService) mocks.verify(mockService))
        .updateAccountRatings(expectedAccountIds);
}

Transaction Management

The Service layer owns the transaction boundary. Each public service method should create its own Unit of Work, do all the work, and commit. If anything fails, the entire transaction rolls back. This is a deliberate design choice — the service method is the atomic unit.

public void complexOperation(Set<Id> accountIds) {
    // One Unit of Work for the entire operation
    fflib_ISObjectUnitOfWork uow = Application.UnitOfWork.newInstance();

    // Step 1: Query data
    List<Account> accounts = AccountsSelector.newInstance().selectById(accountIds);
    List<Opportunity> opps = OpportunitiesSelector.newInstance().selectByAccountId(accountIds);

    // Step 2: Apply domain logic
    AccountsDomain accountDomain = new AccountsDomain(accounts);
    accountDomain.recalculateRatings();

    OpportunitiesDomain oppDomain = new OpportunitiesDomain(opps);
    oppDomain.updateStagesToReflectAccountChanges(accounts);

    // Step 3: Register all changes
    for (Account acc : accounts) {
        uow.registerDirty(acc);
    }
    for (Opportunity opp : opps) {
        uow.registerDirty(opp);
    }

    // Step 4: Commit — all or nothing
    uow.commitWork();
}

Calling Services from Different Entry Points

The beauty of the service layer is that the same business logic is callable from any entry point:

From a Lightning Controller (Aura/LWC):

@AuraEnabled
public static void updateRatings(List<Id> accountIds) {
    IAccountService service = (IAccountService) Application.Service.newInstance(IAccountService.class);
    service.updateAccountRatings(new Set<Id>(accountIds));
}

From an Invocable Method (Flow):

public class AccountServiceInvocable {

    @InvocableMethod(label='Update Account Ratings' description='Recalculates account ratings')
    public static void updateRatings(List<List<Id>> accountIdLists) {
        Set<Id> allIds = new Set<Id>();
        for (List<Id> idList : accountIdLists) {
            allIds.addAll(idList);
        }
        IAccountService service = (IAccountService) Application.Service.newInstance(IAccountService.class);
        service.updateAccountRatings(allIds);
    }
}

From a REST API:

@RestResource(urlMapping='/accounts/ratings/*')
global class AccountRatingsAPI {

    @HttpPost
    global static void updateRatings() {
        RestRequest req = RestContext.request;
        Map<String, Object> body = (Map<String, Object>) JSON.deserializeUntyped(req.requestBody.toString());
        List<Object> rawIds = (List<Object>) body.get('accountIds');

        Set<Id> accountIds = new Set<Id>();
        for (Object rawId : rawIds) {
            accountIds.add((Id) rawId);
        }

        IAccountService service = (IAccountService) Application.Service.newInstance(IAccountService.class);
        service.updateAccountRatings(accountIds);
    }
}

In all three cases, the exact same service method runs. The business logic is written once, tested once, and exposed through multiple channels.


PROJECT: User-Aware Behavior with the Apex Common Library

Let us build a complete working example that ties all three layers together. We will implement a Case management system where the behavior changes depending on who is using it — specifically, whether the user is a Support Agent, a Support Manager, or a Customer (community user).

The Scenario

Our company has these requirements:

  1. Support Agents can create and update cases, but they cannot set priority to “Critical” — only managers can.
  2. Support Managers can do everything agents can, plus set any priority, reassign cases across teams, and close cases with a resolution.
  3. Customer (community) users can create cases but cannot set the internal fields (like Internal_Notes__c or Escalation_Level__c). They also get automatic defaults (Origin = ‘Web’, Priority = ‘Medium’).
  4. When a case is created, the system should automatically create a follow-up task, but the task details differ by user type.

We will assume a custom field Escalation_Level__c (picklist: Low, Medium, High, Critical) and Internal_Notes__c (long text area) on the Case object.

Step 1: The Selector

public class CasesSelector extends fflib_SObjectSelector {

    public static CasesSelector newInstance() {
        return (CasesSelector) Application.Selector.newInstance(Case.SObjectType);
    }

    public Schema.SObjectType getSObjectType() {
        return Case.SObjectType;
    }

    public List<Schema.SObjectField> getSObjectFieldList() {
        return new List<Schema.SObjectField>{
            Case.Id,
            Case.CaseNumber,
            Case.Subject,
            Case.Description,
            Case.Status,
            Case.Priority,
            Case.Origin,
            Case.OwnerId,
            Case.ContactId,
            Case.AccountId,
            Case.Type,
            Case.Reason,
            Case.Escalation_Level__c,
            Case.Internal_Notes__c,
            Case.IsClosed,
            Case.CreatedDate,
            Case.CreatedById
        };
    }

    public List<Schema.SObjectField> getOrderBy() {
        return new List<Schema.SObjectField>{
            Case.CaseNumber
        };
    }

    // Select cases by account
    public List<Case> selectByAccountId(Set<Id> accountIds) {
        return (List<Case>) Database.query(
            newQueryFactory()
                .setCondition('AccountId IN :accountIds')
                .toSOQL()
        );
    }

    // Select open cases by owner
    public List<Case> selectOpenByOwnerId(Set<Id> ownerIds) {
        return (List<Case>) Database.query(
            newQueryFactory()
                .setCondition('OwnerId IN :ownerIds AND IsClosed = false')
                .toSOQL()
        );
    }

    // Select cases with their related account and contact info
    public List<Case> selectByIdWithAccountAndContact(Set<Id> caseIds) {
        fflib_QueryFactory qf = newQueryFactory();
        qf.setCondition('Id IN :caseIds');
        qf.selectField('Account.Name');
        qf.selectField('Account.OwnerId');
        qf.selectField('Contact.Name');
        qf.selectField('Contact.Email');
        return (List<Case>) Database.query(qf.toSOQL());
    }

    // Select escalated cases
    public List<Case> selectEscalatedCases() {
        return (List<Case>) Database.query(
            newQueryFactory()
                .setCondition('Escalation_Level__c IN (\'High\', \'Critical\') AND IsClosed = false')
                .setOrdering('CreatedDate', fflib_QueryFactory.SortOrder.ASCENDING)
                .toSOQL()
        );
    }
}

Step 2: The Domain

This is where the user-aware behavior lives. The domain inspects the running user’s profile or permission set to determine which rules apply:

public class CasesDomain extends fflib_SObjectDomain {

    // User role constants
    private static final String PROFILE_SUPPORT_AGENT = 'Support Agent';
    private static final String PROFILE_SUPPORT_MANAGER = 'Support Manager';
    private static final String PROFILE_CUSTOMER_COMMUNITY = 'Customer Community User';

    private List<Case> cases {
        get { return (List<Case>) Records; }
    }

    public CasesDomain(List<Case> records) {
        super(records);
    }

    // ── Determine the current user's role context ──
    private static String currentUserProfile {
        get {
            if (currentUserProfile == null) {
                currentUserProfile = [
                    SELECT Profile.Name FROM User WHERE Id = :UserInfo.getUserId()
                ].Profile.Name;
            }
            return currentUserProfile;
        }
        set;
    }

    private static Boolean isManager() {
        return currentUserProfile == PROFILE_SUPPORT_MANAGER;
    }

    private static Boolean isAgent() {
        return currentUserProfile == PROFILE_SUPPORT_AGENT;
    }

    private static Boolean isCommunityUser() {
        return currentUserProfile == PROFILE_CUSTOMER_COMMUNITY;
    }

    // ── Before Insert ──
    public override void onBeforeInsert() {
        for (Case c : cases) {
            if (isCommunityUser()) {
                // Community users get automatic defaults
                c.Origin = 'Web';
                if (c.Priority == null) {
                    c.Priority = 'Medium';
                }
                // Clear internal fields — community users should not set these
                c.Internal_Notes__c = null;
                c.Escalation_Level__c = 'Low';
            } else if (isAgent()) {
                // Agents default to phone origin if not set
                if (c.Origin == null) {
                    c.Origin = 'Phone';
                }
                if (c.Escalation_Level__c == null) {
                    c.Escalation_Level__c = 'Low';
                }
            }
            // Managers get no overrides — they control everything

            // Universal defaults
            if (c.Status == null) {
                c.Status = 'New';
            }
        }
    }

    // ── Validation ──
    public override void onValidate() {
        for (Case c : cases) {
            if (String.isBlank(c.Subject)) {
                c.Subject.addError('Case Subject is required.');
            }

            // Agents cannot set Critical priority
            if (isAgent() && c.Priority == 'Critical') {
                c.Priority.addError(
                    'Support Agents cannot set priority to Critical. ' +
                    'Please escalate to a manager.'
                );
            }

            // Community users cannot set High or Critical priority
            if (isCommunityUser() && (c.Priority == 'High' || c.Priority == 'Critical')) {
                c.Priority.addError(
                    'Priority will be evaluated by our support team.'
                );
            }
        }
    }

    public override void onValidate(Map<Id, SObject> existingRecords) {
        Map<Id, Case> oldMap = (Map<Id, Case>) existingRecords;

        for (Case c : cases) {
            Case oldCase = oldMap.get(c.Id);

            // Agents cannot upgrade priority to Critical
            if (isAgent() && c.Priority == 'Critical' && oldCase.Priority != 'Critical') {
                c.Priority.addError('Only managers can set priority to Critical.');
            }

            // Community users cannot change internal fields
            if (isCommunityUser()) {
                if (c.Internal_Notes__c != oldCase.Internal_Notes__c) {
                    c.Internal_Notes__c.addError('You do not have access to modify internal notes.');
                }
                if (c.Escalation_Level__c != oldCase.Escalation_Level__c) {
                    c.Escalation_Level__c.addError('You do not have access to modify escalation level.');
                }
            }

            // Prevent re-opening closed cases unless you are a manager
            if (oldCase.IsClosed && !c.IsClosed && !isManager()) {
                c.Status.addError('Only managers can re-open closed cases.');
            }
        }
    }

    // ── Before Update ──
    public override void onBeforeUpdate(Map<Id, SObject> existingRecords) {
        Map<Id, Case> oldMap = (Map<Id, Case>) existingRecords;

        for (Case c : cases) {
            Case oldCase = oldMap.get(c.Id);

            // Auto-escalate if priority changes to Critical (manager action)
            if (isManager() && c.Priority == 'Critical' && oldCase.Priority != 'Critical') {
                c.Escalation_Level__c = 'Critical';
            }
        }
    }

    // ── After Insert ──
    public override void onAfterInsert() {
        List<Task> followUpTasks = new List<Task>();

        for (Case c : cases) {
            Task t = new Task(
                WhatId = c.Id,
                OwnerId = c.OwnerId,
                ActivityDate = Date.today().addDays(1),
                Status = 'Not Started'
            );

            if (isCommunityUser()) {
                // For community-created cases: generic acknowledgment task
                t.Subject = 'Review new customer case: ' + c.Subject;
                t.Priority = 'High';
                t.Description = 'A customer submitted a new case via the portal. '
                    + 'Review and assign to the appropriate team within 4 hours.';
            } else if (isAgent()) {
                // For agent-created cases: standard follow-up
                t.Subject = 'Follow up on case: ' + c.Subject;
                t.Priority = 'Normal';
                t.Description = 'Ensure initial response has been sent to the customer.';
            } else if (isManager()) {
                // For manager-created cases: likely escalated or high-priority
                t.Subject = 'Manager-created case requires attention: ' + c.Subject;
                t.Priority = 'High';
                t.Description = 'This case was created directly by a manager. '
                    + 'Assign a senior agent and begin investigation immediately.';
                t.ActivityDate = Date.today(); // Same day for manager cases
            }

            followUpTasks.add(t);
        }

        if (!followUpTasks.isEmpty()) {
            insert followUpTasks;
        }
    }

    // ── Public domain methods (called by the service layer) ──
    public void reassignToTeam(Id newOwnerId) {
        if (!isManager()) {
            throw new CaseServiceException('Only managers can reassign cases across teams.');
        }

        for (Case c : cases) {
            c.OwnerId = newOwnerId;
        }
    }

    public void closeWithResolution(String resolution) {
        for (Case c : cases) {
            if (isCommunityUser()) {
                throw new CaseServiceException('Customers cannot close cases directly.');
            }
            c.Status = 'Closed';
            c.Internal_Notes__c = (c.Internal_Notes__c != null ? c.Internal_Notes__c + '\n' : '')
                + '[' + DateTime.now().format() + '] Closed by ' + UserInfo.getName()
                + ': ' + resolution;
        }
    }

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

Step 3: The Service

public interface ICaseService {

    Id createCase(Case newCase);
    void updateCasePriority(Set<Id> caseIds, String newPriority);
    void reassignCases(Set<Id> caseIds, Id newOwnerId);
    void closeCases(Set<Id> caseIds, String resolution);
    List<Case> getEscalatedCases();
    List<Case> getMyCases();
}
public class CaseServiceImpl implements ICaseService {

    public Id createCase(Case newCase) {
        fflib_ISObjectUnitOfWork uow = Application.UnitOfWork.newInstance();
        uow.registerNew(newCase);
        uow.commitWork();
        return newCase.Id;
    }

    public void updateCasePriority(Set<Id> caseIds, String newPriority) {
        fflib_ISObjectUnitOfWork uow = Application.UnitOfWork.newInstance();

        List<Case> cases = CasesSelector.newInstance().selectById(caseIds);

        // The domain validation will enforce who can set which priority
        for (Case c : cases) {
            c.Priority = newPriority;
            uow.registerDirty(c);
        }

        uow.commitWork();
    }

    public void reassignCases(Set<Id> caseIds, Id newOwnerId) {
        fflib_ISObjectUnitOfWork uow = Application.UnitOfWork.newInstance();

        List<Case> cases = CasesSelector.newInstance().selectById(caseIds);

        // Use the domain method which checks permissions
        CasesDomain domain = new CasesDomain(cases);
        domain.reassignToTeam(newOwnerId);

        for (Case c : cases) {
            uow.registerDirty(c);
        }

        uow.commitWork();
    }

    public void closeCases(Set<Id> caseIds, String resolution) {
        fflib_ISObjectUnitOfWork uow = Application.UnitOfWork.newInstance();

        List<Case> cases = CasesSelector.newInstance().selectById(caseIds);

        CasesDomain domain = new CasesDomain(cases);
        domain.closeWithResolution(resolution);

        for (Case c : cases) {
            uow.registerDirty(c);
        }

        uow.commitWork();
    }

    public List<Case> getEscalatedCases() {
        return CasesSelector.newInstance().selectEscalatedCases();
    }

    public List<Case> getMyCases() {
        return CasesSelector.newInstance().selectOpenByOwnerId(
            new Set<Id>{ UserInfo.getUserId() }
        );
    }
}

Step 4: The Custom Exception

public class CaseServiceException extends Exception {}

Step 5: Register Everything in the Application Class

public class Application {

    public static final fflib_Application.SelectorFactory Selector =
        new fflib_Application.SelectorFactory(
            new Map<SObjectType, Type>{
                Case.SObjectType => CasesSelector.class
            }
        );

    public static final fflib_Application.DomainFactory Domain =
        new fflib_Application.DomainFactory(
            Application.Selector,
            new Map<SObjectType, Type>{
                Case.SObjectType => CasesDomain.Constructor.class
            }
        );

    public static final fflib_Application.ServiceFactory Service =
        new fflib_Application.ServiceFactory(
            new Map<Type, Type>{
                ICaseService.class => CaseServiceImpl.class
            }
        );

    public static final fflib_Application.UnitOfWorkFactory UnitOfWork =
        new fflib_Application.UnitOfWorkFactory(
            new List<SObjectType>{
                Account.SObjectType,
                Contact.SObjectType,
                Case.SObjectType,
                Task.SObjectType
            }
        );
}

Step 6: The Trigger

trigger CaseTrigger on Case (
    before insert, before update, before delete,
    after insert, after update, after delete, after undelete
) {
    fflib_SObjectDomain.triggerHandler(CasesDomain.class);
}

Step 7: Calling from a Lightning Controller

public class CaseController {

    @AuraEnabled
    public static Id createCase(String subject, String description, String priority) {
        Case newCase = new Case(
            Subject = subject,
            Description = description,
            Priority = priority
        );

        ICaseService service = (ICaseService) Application.Service.newInstance(ICaseService.class);
        return service.createCase(newCase);
    }

    @AuraEnabled
    public static void updatePriority(List<Id> caseIds, String priority) {
        ICaseService service = (ICaseService) Application.Service.newInstance(ICaseService.class);
        service.updateCasePriority(new Set<Id>(caseIds), priority);
    }

    @AuraEnabled
    public static void reassignCases(List<Id> caseIds, Id newOwnerId) {
        ICaseService service = (ICaseService) Application.Service.newInstance(ICaseService.class);
        service.reassignCases(new Set<Id>(caseIds), newOwnerId);
    }

    @AuraEnabled
    public static void closeCases(List<Id> caseIds, String resolution) {
        ICaseService service = (ICaseService) Application.Service.newInstance(ICaseService.class);
        service.closeCases(new Set<Id>(caseIds), resolution);
    }

    @AuraEnabled(cacheable=true)
    public static List<Case> getEscalatedCases() {
        ICaseService service = (ICaseService) Application.Service.newInstance(ICaseService.class);
        return service.getEscalatedCases();
    }

    @AuraEnabled(cacheable=true)
    public static List<Case> getMyCases() {
        ICaseService service = (ICaseService) Application.Service.newInstance(ICaseService.class);
        return service.getMyCases();
    }
}

How It All Flows Together

Let us trace what happens when a community user creates a case:

  1. The LWC calls CaseController.createCase('Billing issue', 'I was overcharged', 'High').
  2. The controller creates a Case SObject and calls ICaseService.createCase().
  3. The service creates a Unit of Work, registers the new case, and calls commitWork().
  4. The Unit of Work issues insert newCase, which fires the Case trigger.
  5. The trigger calls fflib_SObjectDomain.triggerHandler(CasesDomain.class), which routes to onBeforeInsert().
  6. onBeforeInsert() detects the community user profile, sets Origin to ‘Web’, clears Internal_Notes__c, and sets Escalation_Level__c to ‘Low’.
  7. The framework calls onValidate(). It detects that the community user tried to set priority to ‘High’ and adds an error, blocking the insert.
  8. The user receives the error message and resubmits with priority ‘Medium’.
  9. This time validation passes. The record is saved. The after insert trigger fires.
  10. onAfterInsert() creates a high-priority review task: “Review new customer case: Billing issue.”

Now contrast that with a manager creating the same case — no fields are overridden, no priority restrictions apply, and the follow-up task has different wording and a same-day due date. Same code path, different behavior, all driven by the user context within the domain layer.

Testing with Mocks

The service interface pattern makes testing the controller trivially easy:

@IsTest
static void testCreateCaseCallsService() {
    fflib_ApexMocks mocks = new fflib_ApexMocks();
    ICaseService mockService = (ICaseService) mocks.mock(ICaseService.class);
    Application.Service.setMock(ICaseService.class, mockService);

    // Stub the return value
    Id fakeCaseId = fflib_IDGenerator.generate(Case.SObjectType);
    mocks.startStubbing();
    mocks.when(mockService.createCase((Case) fflib_Match.anyObject()))
         .thenReturn(fakeCaseId);
    mocks.stopStubbing();

    // Call the controller
    Id result = CaseController.createCase('Test Subject', 'Test Description', 'Medium');

    // Verify the service was called
    ((ICaseService) mocks.verify(mockService))
        .createCase((Case) fflib_Match.anyObject());

    System.assertEquals(fakeCaseId, result);
}

No DML. No SOQL. No records created. The test runs in milliseconds and verifies that the controller correctly delegates to the service. You write separate tests for the service, domain, and selector in isolation. This is how large-scale Salesforce applications achieve fast, reliable test suites.


Key Takeaways

  1. fflib is the implementation of the enterprise patterns. The patterns (Selector, Domain, Service, Unit of Work) are the architecture. The Apex Common Library gives you the base classes and factories to build on.

  2. The Application class is the central registry. Every SObject in your system maps to a selector, domain, and (optionally) a service. This is how the factory pattern enables runtime substitution and test mocking.

  3. Selectors own all queries. Every SOQL statement in your application goes through a selector. This gives you a single place to manage field lists, enforce FLS, and optimize queries. The QueryFactory makes it easy to build dynamic queries while maintaining the standard field set.

  4. Domain classes own business logic and trigger handling. The trigger file becomes a one-liner. All validation, defaults, and record-level logic live in the domain class where they can be tested in isolation and reused across trigger events and service methods.

  5. Services own the transaction boundary. They are the public API of your business logic. Controllers, APIs, Flows, and batch jobs all call the same service methods. The Unit of Work ensures atomicity.

  6. User-aware behavior belongs in the Domain layer. By inspecting the running user’s profile, permissions, or custom settings within domain methods, you create a single codebase that adapts its behavior to different user personas without duplicating logic.

  7. Interfaces enable mocking. The service interface pattern lets you test each layer in isolation. Controller tests mock the service. Service tests mock the selector. This eliminates the need for heavyweight integration tests for every code path.


What Is Next?

In Part 65, we will explore Design Patterns in Apex — the Strategy pattern, Factory pattern, Singleton pattern, Decorator pattern, and more. We will look at how classic software engineering patterns translate to the Salesforce platform and when each one is the right tool for the job. The enterprise patterns we covered today are just the beginning — there is a whole world of design patterns that can make your Apex code cleaner, more flexible, and easier to maintain. See you there.