LearnFrenzy
Sign in Take a free test

Professional Apex and Interview Prep · Lesson 39 of 41

Apex Design Patterns: Service, Selector, Domain and More

7 min read Updated Nov 8, 2026 Saurabh Samir

A ten-class org can be organised any way you like. A five-hundred-class org can't. When several teams work on the same objects for years, structure is what keeps changes safe. The patterns below – popularised as the Apex Enterprise Patterns and the open-source fflib library – are what most large Salesforce codebases use in some form.

In this lesson

  • Separation of concerns: the layers
  • Selector, service and domain classes
  • Unit of work
  • Singleton and factory
  • Configuration with Custom Metadata

Layers of an enterprise Apex app

  1. Entry pointstriggers, LWC controllers, REST resources, batch jobs, invocable actions – thin, no business logic
  2. Service layerbusiness operations: OpportunityService.closeWon(ids) – used by every entry point
  3. Domain layerrules about one object's records: defaults, validation, derived fields
  4. Selector layerall SOQL for an object in one class – consistent fields and security
  5. Unit of workcollects DML and commits it once, in the right order

Selector: one home for each object's queries

// OpportunitySelector.cls
public inherited sharing class OpportunitySelector {
    private static final List<String> FIELDS = new List<String>{ 'Id', 'Name', 'Amount', 'StageName', 'AccountId', 'IsClosed' };

    public List<Opportunity> selectByIds(Set<Id> ids) {
        return Database.queryWithBinds(
            'SELECT ' + String.join(FIELDS, ', ') + ' FROM Opportunity WHERE Id IN :ids',
            new Map<String, Object>{ 'ids' => ids }, AccessLevel.USER_MODE);
    }

    public List<Opportunity> selectOpenByAccount(Set<Id> accountIds) {
        return Database.queryWithBinds(
            'SELECT ' + String.join(FIELDS, ', ') + ' FROM Opportunity WHERE AccountId IN :accountIds AND IsClosed = false',
            new Map<String, Object>{ 'accountIds' => accountIds }, AccessLevel.USER_MODE);
    }
}

Every query on Opportunity now returns the same fields (no more "field not queried" exceptions), runs in user mode, and can be changed in one place.

Service: business operations

// OpportunityService.cls
public with sharing class OpportunityService {
    public class ServiceException extends Exception {}

    // one business operation, bulk by design, callable from anywhere
    public static void closeWon(Set<Id> opportunityIds) {
        List<Opportunity> opps = new OpportunitySelector().selectByIds(opportunityIds);
        UnitOfWork uow = new UnitOfWork();
        for (Opportunity opp : opps) {
            if (opp.IsClosed) throw new ServiceException(opp.Name + ' is already closed');
            uow.registerDirty(new Opportunity(Id = opp.Id, StageName = 'Closed Won'));
            uow.registerNew(new Task(Subject = 'Kick-off: ' + opp.Name, WhatId = opp.Id));
            if (opp.AccountId != null) uow.registerDirty(new Account(Id = opp.AccountId, Rating = 'Hot'));
        }
        uow.commitWork();
    }
}

The trigger handler, the LWC controller and a batch job all call OpportunityService.closeWon – the rule lives in exactly one place.

Unit of work

A unit of work collects the records to insert and update, then saves them in one go – one DML per object type, inside a savepoint, so a failure leaves nothing half-done. fflib's fflib_SObjectUnitOfWork is the full version (it also resolves parent–child relationships); here's the core idea:

// UnitOfWork.cls
public inherited sharing class UnitOfWork {
    private final List<SObject> newRecords = new List<SObject>();
    private final Map<Id, SObject> dirtyRecords = new Map<Id, SObject>();

    public void registerNew(SObject record) { newRecords.add(record); }

    public void registerDirty(SObject record) {
        SObject existing = dirtyRecords.get(record.Id);
        if (existing == null) {
            dirtyRecords.put(record.Id, record);
        } else {
            Map<String, Object> changes = record.getPopulatedFieldsAsMap();
            for (String field : changes.keySet()) {                   // merge changes to the same record
                if (field != 'Id') existing.put(field, changes.get(field));
            }
        }
    }

    public void commitWork() {
        Savepoint sp = Database.setSavepoint();
        try {
            List<SObject> dirty = dirtyRecords.values();
            dirty.sort();                                            // groups records by object type
            update dirty;
            insert newRecords;
        } catch (Exception e) {
            Database.rollback(sp);
            throw e;
        }
    }
}
Account acc = new Account(Name = 'Acme Corp', Rating = 'Cold');
insert acc;
List<Opportunity> opps = new List<Opportunity>{
    new Opportunity(Name = 'Acme renewal', AccountId = acc.Id, StageName = 'Negotiation/Review', CloseDate = Date.today(), Amount = 90000),
    new Opportunity(Name = 'Acme add-on', AccountId = acc.Id, StageName = 'Proposal/Price Quote', CloseDate = Date.today(), Amount = 15000)
};
insert opps;

Integer dmlBefore = Limits.getDmlStatements();
OpportunityService.closeWon(new Map<Id, Opportunity>(opps).keySet());
System.debug('DML statements used by closeWon: ' + (Limits.getDmlStatements() - dmlBefore));

System.debug('Won: ' + [SELECT COUNT() FROM Opportunity WHERE StageName = 'Closed Won']);
System.debug('Tasks: ' + [SELECT COUNT() FROM Task]);
System.debug('Account rating: ' + [SELECT Rating FROM Account WHERE Id = :acc.Id].Rating);

try {
    OpportunityService.closeWon(new Map<Id, Opportunity>(opps).keySet());
} catch (OpportunityService.ServiceException e) {
    System.debug('Second call: ' + e.getMessage());
}

Debug log

DML statements used by closeWon: 3
Won: 2
Tasks: 2
Account rating: Hot
Second call: Acme renewal is already closed

Two opportunities, two tasks and one account update cost three DML statements: the savepoint, one update for all the dirty records (two opportunities plus the account, which was registered twice and merged), and one insert for the tasks. Because both happen inside the savepoint, a failure in either would leave nothing behind. The second call was refused by the service's own rule before any DML ran.

Domain: rules about an object

A domain class wraps a list of records of one object and holds the rules that belong to them – defaults, validation, calculated fields – typically called from the trigger handler:

public with sharing class Opportunities {
    private final List<Opportunity> records;
    public Opportunities(List<Opportunity> records) { this.records = records; }

    public void applyDefaults() {
        for (Opportunity o : records) {
            if (o.Probability == null) o.Probability = 10;
            if (o.CloseDate == null) o.CloseDate = Date.today().addDays(90);
        }
    }

    public void validate() {
        for (Opportunity o : records) {
            if (o.Amount != null && o.Amount < 0) o.Amount.addError('Amount cannot be negative');
        }
    }
}
// in the handler: new Opportunities(Trigger.new).applyDefaults();

Singleton and factory

// TaxSettings.cls
public with sharing class TaxSettings {
    private static TaxSettings instance;                    // one per transaction
    public static Integer loads = 0;
    public final Map<String, Decimal> rates;

    private TaxSettings() {                                 // private: use getInstance()
        loads++;
        // in a real org: query Custom Metadata, e.g. Tax_Rate__mdt.getAll()
        rates = new Map<String, Decimal>{ 'IN' => 0.18, 'DE' => 0.19, 'GB' => 0.20 };
    }

    public static TaxSettings getInstance() {
        if (instance == null) instance = new TaxSettings();
        return instance;
    }
}
for (Integer i = 0; i < 5; i++) {
    Decimal rate = TaxSettings.getInstance().rates.get('IN');
}
System.debug('Rate for DE: ' + TaxSettings.getInstance().rates.get('DE'));
System.debug('Settings loaded ' + TaxSettings.loads + ' time(s) for 6 calls');

Debug log

Rate for DE: 0.19
Settings loaded 1 time(s) for 6 calls

The singleton loads expensive configuration once per transaction, no matter how many classes ask for it. A factory goes one step further: a method that decides which implementation to return – often from Custom Metadata through Type.forName, as shown in the interfaces lesson – so behaviour can be configured without code changes.

Configuration over code: Custom Metadata

Hard-coded values (thresholds, mappings, which handler to run) belong in Custom Metadata Types. They deploy with your code, are readable in Apex without counting against SOQL limits (Tax_Rate__mdt.getAll(), getInstance('IN')), and admins can change them in Setup. Custom Labels hold user-facing text; Custom Settings (hierarchy) suit per-user or per-profile switches such as trigger bypasses.

Practice

Try it yourself

  1. Three classes each query Account with different field lists, and one keeps failing with "SObject row was retrieved via SOQL without querying the requested field". Which pattern fixes this for good?

    Show answer

    A selector class (AccountSelector) that owns all Account queries with a consistent field list; the three classes call it instead of writing SOQL.

  2. Where should "when an opportunity is won, create a kick-off task" live: the trigger, the LWC controller or a service?

    Show answer

    A service method, called by both the trigger handler and the controller, so the rule exists once.

Quick check

What does the selector layer own?

All the SOQL for one object.

What problem does a unit of work solve?

Scattered DML – it batches inserts and updates per object and commits them together.

How long does an Apex singleton live?

One transaction, like any static variable.

Summary

  • Keep entry points thin; put business operations in services.
  • Selectors own queries; domain classes own per-object rules.
  • A unit of work batches DML and commits it safely.
  • Singletons cache per transaction; Custom Metadata replaces hard-coded configuration.

Finished this lesson?

Mark it complete to track your progress, then carry on with the next lesson.

Next lesson

Popular