I spent my first two years on Salesforce writing Apex that worked — barely. The triggers had hundreds of lines of business logic. The test classes tested implementation details instead of behavior. Every new requirement meant digging through spaghetti code and hoping I did not break something three layers away.
The shift happened when I studied how experienced architects structured large Apex codebases. The patterns they used were not Salesforce-specific inventions — they were classic object-oriented design patterns adapted to the constraints of the Apex platform. Understanding these seven patterns changed my code from something that works to something that scales.
The Problem
A growing org has 15 triggers, each with hundreds of lines of business logic written directly in the trigger file. Three developers are making concurrent changes. Test classes test internal implementation details instead of behavior. A new requirement that touches four objects requires tracing logic through 600 lines of spaghetti code spread across multiple files — and every change risks breaking something untested three layers away.
The Solution
Apply the layered architecture: thin triggers delegate to Domain classes, Domain classes call Service layer methods, Services use Selector classes for SOQL and a Unit of Work for DML. Each layer has a single responsibility and can be independently unit-tested. Adding a new requirement means adding a method to the appropriate Service — not hunting through trigger files.
Pattern Overview
Pattern 1: Singleton
The Singleton pattern ensures a class has only one instance per transaction. In Apex, static variables persist for the life of a transaction, making this pattern a natural fit.
The primary use case: preventing re-entrant trigger execution.
public class TriggerContext {
private static TriggerContext instance;
private Set<Id> processedAccountIds = new Set<Id>();
private Set<Id> processedContactIds = new Set<Id>();
private TriggerContext() {}
public static TriggerContext getInstance() {
if (instance == null) {
instance = new TriggerContext();
}
return instance;
}
public Boolean isNewAccount(Id accountId) {
return !processedAccountIds.contains(accountId);
}
public void markAccountProcessed(Id accountId) {
processedAccountIds.add(accountId);
}
}
// Usage in trigger
trigger AccountTrigger on Account (after update) {
TriggerContext ctx = TriggerContext.getInstance();
List<Account> toProcess = new List<Account>();
for (Account acc : Trigger.new) {
if (ctx.isNewAccount(acc.Id)) {
ctx.markAccountProcessed(acc.Id);
toProcess.add(acc);
}
}
AccountService.handleUpdate(toProcess, Trigger.oldMap);
}When a workflow field update causes the trigger to fire a second time, processedAccountIds still contains the IDs from the first pass. The second pass finds nothing to process and exits cleanly.
Pattern 2: Strategy
The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. In Apex, this typically means an interface with multiple implementations.
Use case: discount calculation that varies by customer tier.
public interface DiscountStrategy {
Decimal calculateDiscount(Decimal baseAmount);
}
public class StandardDiscount implements DiscountStrategy {
public Decimal calculateDiscount(Decimal baseAmount) {
return baseAmount * 0.05; // 5%
}
}
public class PlatinumDiscount implements DiscountStrategy {
public Decimal calculateDiscount(Decimal baseAmount) {
return baseAmount * 0.20; // 20%
}
}
public class PartnerDiscount implements DiscountStrategy {
public Decimal calculateDiscount(Decimal baseAmount) {
return (baseAmount * 0.15) + 500; // 15% + flat $500
}
}
public class QuoteService {
public static void applyDiscounts(List<Quote> quotes, Map<Id, Account> accounts) {
Map<String, DiscountStrategy> strategies = new Map<String, DiscountStrategy>{
'Standard' => new StandardDiscount(),
'Platinum' => new PlatinumDiscount(),
'Partner' => new PartnerDiscount()
};
for (Quote q : quotes) {
Account acct = accounts.get(q.AccountId);
String tier = acct.Support_Tier__c != null ? acct.Support_Tier__c : 'Standard';
DiscountStrategy strategy = strategies.get(tier);
if (strategy == null) strategy = strategies.get('Standard');
q.Discount__c = strategy.calculateDiscount(q.Subtotal__c);
}
}
}Adding a new tier is one new class implementing DiscountStrategy and one line in the strategies map. No changes to existing logic.
Pattern 3: Factory
The Factory pattern creates objects without specifying the exact class. Combined with Strategy, it keeps instantiation logic centralized.
public class DiscountStrategyFactory {
public static DiscountStrategy create(String tier) {
switch on tier {
when 'Platinum' { return new PlatinumDiscount(); }
when 'Partner' { return new PartnerDiscount(); }
when else { return new StandardDiscount(); }
}
}
// Type-safe factory using Type.forName for dynamic instantiation
public static DiscountStrategy createDynamic(String className) {
Type strategyType = Type.forName(className);
if (strategyType == null) {
throw new IllegalArgumentException('Unknown strategy class: ' + className);
}
return (DiscountStrategy) strategyType.newInstance();
}
}Type.forName() is the Apex mechanism that enables true plugin-style extensibility. Store strategy class names in Custom Metadata and your Factory can load new strategies without any code changes.
Pattern 4: Selector
Every SOQL query in your codebase that is not in a Selector class is technical debt. The Selector pattern encapsulates all queries for a given object in one place.
public class AccountSelector {
private static final Set<String> BASE_FIELDS = new Set<String>{
'Id', 'Name', 'Type', 'AnnualRevenue',
'BillingCity', 'BillingCountry', 'OwnerId', 'Support_Tier__c'
};
public static List<Account> selectById(Set<Id> ids) {
return [
SELECT Id, Name, Type, AnnualRevenue, BillingCity,
BillingCountry, OwnerId, Support_Tier__c
FROM Account
WHERE Id IN :ids
AND IsDeleted = false
];
}
public static List<Account> selectByTypeWithContacts(String type) {
return [
SELECT Id, Name, Type, AnnualRevenue,
(SELECT Id, FirstName, LastName, Email FROM Contacts WHERE IsDeleted = false)
FROM Account
WHERE Type = :type
AND IsDeleted = false
ORDER BY Name
];
}
public static Map<Id, Account> selectByIdAsMap(Set<Id> ids) {
return new Map<Id, Account>(selectById(ids));
}
}
// Usage — no SOQL anywhere else in the codebase for Account queries
List<Account> accounts = AccountSelector.selectById(accountIds);The Selector pattern makes security reviews dramatically faster. When a security auditor asks “show me every query that accesses Account records,” you point them to one file. Without it, they need to grep through the entire codebase. If you are pursuing ISV certification or SOC 2 compliance, centralizing SOQL in Selectors is one of the highest-value steps you can take.
Pattern 5: Domain Layer
The Domain layer is a trigger handler — a class that represents the business behavior associated with a specific SObject. It is where the trigger logic lives, replacing the anti-pattern of putting code directly in trigger files.
public class AccountDomain {
private List<Account> newRecords;
private Map<Id, Account> oldMap;
public AccountDomain(List<Account> newRecords, Map<Id, Account> oldMap) {
this.newRecords = newRecords;
this.oldMap = oldMap;
}
public void onBeforeInsert() {
setDefaultValues();
}
public void onBeforeUpdate() {
validateStatusTransitions();
setDefaultValues();
}
public void onAfterInsert() {
List<Id> newIds = new List<Id>();
for (Account a : newRecords) newIds.add(a.Id);
AccountService.createDefaultContacts(newIds);
}
private void setDefaultValues() {
for (Account a : newRecords) {
if (a.Type == null) a.Type = 'Prospect';
if (a.Rating == null) a.Rating = 'Cold';
}
}
private void validateStatusTransitions() {
for (Account a : newRecords) {
Account old = oldMap.get(a.Id);
if (old.Type == 'Customer' && a.Type == 'Prospect') {
a.addError('Cannot move a Customer back to Prospect status.');
}
}
}
}
// The trigger itself is just a dispatcher
trigger AccountTrigger on Account (before insert, before update, after insert) {
AccountDomain domain = new AccountDomain(Trigger.new, Trigger.oldMap);
if (Trigger.isBefore) {
if (Trigger.isInsert) domain.onBeforeInsert();
if (Trigger.isUpdate) domain.onBeforeUpdate();
}
if (Trigger.isAfter) {
if (Trigger.isInsert) domain.onAfterInsert();
}
}The trigger file is four lines of delegation. All logic is in the Domain class, which is independently unit-testable without DML.
Pattern 6: Service Layer
The Service layer contains cross-object business logic and orchestration that does not belong in any single Domain class. It is stateless and contains methods that represent business operations.
public class AccountService {
public static void createDefaultContacts(List<Id> accountIds) {
List<Account> accounts = AccountSelector.selectById(new Set<Id>(accountIds));
List<Contact> newContacts = new List<Contact>();
for (Account acc : accounts) {
newContacts.add(new Contact(
AccountId = acc.Id,
FirstName = 'Primary',
LastName = acc.Name + ' Contact',
Email = 'primary@' + acc.Name.toLowerCase().replaceAll('[^a-z0-9]', '') + '.com'
));
}
if (!newContacts.isEmpty()) {
insert newContacts;
}
}
public static void mergeAccountData(Id masterId, List<Id> duplicateIds) {
// Cross-object operation too complex for any single Domain class
List<Account> accounts = AccountSelector.selectById(
new Set<Id>{ masterId }
);
// ... complex merge logic
}
}Service layer methods are the primary candidates for @InvocableMethod — they are already the clean, bulkified business operation that Flows should call.
Pattern 7: Unit of Work
The Unit of Work pattern collects all DML operations throughout a transaction and executes them in a single bulk commit at the end. This is the pattern that prevents “DML in a loop” from ever happening accidentally.
public class UnitOfWork {
private List<SObject> toInsert = new List<SObject>();
private List<SObject> toUpdate = new List<SObject>();
private List<SObject> toDelete = new List<SObject>();
public void registerNew(SObject record) {
toInsert.add(record);
}
public void registerDirty(SObject record) {
toUpdate.add(record);
}
public void registerDeleted(SObject record) {
toDelete.add(record);
}
public void commitWork() {
if (!toInsert.isEmpty()) insert toInsert;
if (!toUpdate.isEmpty()) update toUpdate;
if (!toDelete.isEmpty()) delete toDelete;
toInsert.clear();
toUpdate.clear();
toDelete.clear();
}
}
// The Service layer registers work, but never does DML itself
public class OpportunityService {
public static void processClosedOpportunities(
List<Opportunity> opps,
UnitOfWork uow
) {
List<Id> accountIds = new List<Id>();
for (Opportunity o : opps) accountIds.add(o.AccountId);
Map<Id, Account> accountMap = AccountSelector.selectByIdAsMap(new Set<Id>(accountIds));
for (Opportunity o : opps) {
Account acct = accountMap.get(o.AccountId);
acct.Last_Won_Opportunity_Date__c = Date.today();
uow.registerDirty(acct);
Task followUp = new Task(
WhatId = o.Id,
Subject = 'Post-close follow up',
Status = 'Not Started',
OwnerId = o.OwnerId
);
uow.registerNew(followUp);
}
}
}
// The trigger controls when DML actually happens
trigger OpportunityTrigger on Opportunity (after update) {
List<Opportunity> closedWon = new List<Opportunity>();
for (Opportunity o : Trigger.new) {
if (o.StageName == 'Closed Won' && Trigger.oldMap.get(o.Id).StageName != 'Closed Won') {
closedWon.add(o);
}
}
if (!closedWon.isEmpty()) {
UnitOfWork uow = new UnitOfWork();
OpportunityService.processClosedOpportunities(closedWon, uow);
uow.commitWork(); // ONE DML for all accounts, ONE DML for all tasks
}
}No matter how many Service classes or Domain classes are called, DML happens once at the end. The entire transaction uses 2 DML statements instead of potentially 200.
How the Patterns Work Together
Full call flow for 200 records through all layers
These seven patterns are most powerful when used together. The Trigger dispatches to the Domain. The Domain calls the Service. The Service uses Selectors for SOQL and registers work with the Unit of Work. The Service uses the Factory and Strategy for complex, variant behavior. The Singleton prevents re-entrant processing.
Here is the call flow for a trigger that processes 200 records:
AccountTrigger
-> AccountDomain.onAfterInsert()
-> AccountService.createDefaultContacts(200 ids)
|-- AccountSelector.selectById(200 ids) // 1 SOQL
|-- Strategy/Factory for each account type
|-- uow.registerNew(200 contacts)
|-- uow.commitWork() // 1 DML insertTotal: 1 SOQL, 1 DML, regardless of whether 1 or 200 records were in the trigger batch.
When Not to Apply All Patterns
These patterns have overhead. For a small, simple org with two developers and fifteen triggers, the full stack may be over-engineering. I apply the full architecture when:
- The org has more than 20 triggers or complex cross-object logic
- Multiple developers are working on the same codebase simultaneously
- There is a CI/CD pipeline with code coverage requirements
- The org will need to scale to handle large data volumes
For a solo admin who writes Apex occasionally, a well-organized trigger class with a helper class is sufficient — and sustainable by the next person who touches it.
Which of these patterns have you adopted in your org? And which have you tried to introduce that met the most resistance from your team? The Unit of Work tends to be the hardest sell in my experience — people find it abstract until they see it eliminate a DML-in-a-loop bug in production.
How did this article make you feel?
Comments
Salesforce Tip