Part 65: Design Patterns in Apex
Welcome back to the Salesforce series. In the previous posts we covered clean code, SOLID principles, and general best practices for writing maintainable Apex. Now it is time to go deeper. This is Part 65, and it is entirely about design patterns — the reusable solutions to common software design problems that the Gang of Four catalogued decades ago.
Design patterns are not Salesforce inventions. They come from the 1994 book Design Patterns: Elements of Reusable Object-Oriented Software by Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides. But the patterns are language-agnostic ideas, and they translate remarkably well into Apex. Some translate directly. Others need adaptation because of Apex governor limits, the lack of certain language features, or the platform event architecture that Salesforce provides.
We will cover three families of patterns: creational patterns that control how objects are born, structural patterns that control how objects are composed, and behavioral patterns that control how objects communicate. For each pattern, we will discuss what it is, when you would use it on the Salesforce platform, and then look at a concrete Apex implementation. At the end, we will bring several patterns together in a single project.
This is a long post. Bookmark it and come back to it when you need a reference.
Creational Patterns
Creational patterns deal with object creation. In Apex, object creation seems simple — you call new and you are done. But in real projects, the creation logic can become tangled. Creational patterns give you clean ways to control what gets created, when it gets created, and how many instances exist.
1. Singleton Pattern
The Singleton pattern ensures that a class has only one instance and provides a global point of access to it. In Apex, this is one of the most commonly used patterns because of governor limits. If you have a utility class that loads configuration data, you do not want to query Custom Metadata every time someone calls a method. You want to load it once and reuse it.
When to Use in Salesforce
- Configuration managers that read Custom Metadata or Custom Settings once per transaction.
- Logger classes where a single instance collects log entries throughout a transaction and flushes them at the end.
- Any service that is expensive to initialize and should only be initialized once per execution context.
Apex Example: ConfigManager
public class ConfigManager {
private static ConfigManager instance;
private Map<String, App_Config__mdt> configMap;
// Private constructor prevents external instantiation
private ConfigManager() {
configMap = new Map<String, App_Config__mdt>();
for (App_Config__mdt config : App_Config__mdt.getAll().values()) {
configMap.put(config.DeveloperName, config);
}
}
// Lazy initialization — instance created on first access
public static ConfigManager getInstance() {
if (instance == null) {
instance = new ConfigManager();
}
return instance;
}
public String getValue(String developerName) {
App_Config__mdt config = configMap.get(developerName);
if (config == null) {
throw new ConfigException('No config found: ' + developerName);
}
return config.Value__c;
}
public Boolean hasConfig(String developerName) {
return configMap.containsKey(developerName);
}
public class ConfigException extends Exception {}
}
Usage is straightforward:
String apiEndpoint = ConfigManager.getInstance().getValue('API_Endpoint');
String apiKey = ConfigManager.getInstance().getValue('API_Key');
The first call creates the instance and queries Custom Metadata. Every subsequent call in the same transaction reuses the already-loaded data. No duplicate SOQL queries. No wasted governor limits.
A key point: Apex singletons are scoped to a single transaction. There is no persistent in-memory state between transactions like you might have in a Java application server. Each transaction starts fresh. This actually makes the Singleton safer in Apex than in many other environments because you do not need to worry about thread safety or stale state across requests.
2. Builder Pattern
The Builder pattern separates the construction of a complex object from its representation. Instead of a constructor with ten parameters, you chain method calls that each set one piece of the object. The result is code that reads like a sentence.
When to Use in Salesforce
- Building SOQL queries dynamically based on user filters.
- Constructing email messages with optional fields like CC, BCC, attachments, and templates.
- Assembling complex SObject records where different fields are set conditionally.
Apex Example: SOQLBuilder
public class SOQLBuilder {
private String objectName;
private List<String> fields;
private List<String> conditions;
private String orderByField;
private String sortDirection;
private Integer limitCount;
public SOQLBuilder(String objectName) {
this.objectName = objectName;
this.fields = new List<String>();
this.conditions = new List<String>();
}
public SOQLBuilder selectField(String fieldName) {
fields.add(String.escapeSingleQuotes(fieldName));
return this;
}
public SOQLBuilder selectFields(List<String> fieldNames) {
for (String f : fieldNames) {
fields.add(String.escapeSingleQuotes(f));
}
return this;
}
public SOQLBuilder whereCondition(String condition) {
conditions.add(condition);
return this;
}
public SOQLBuilder orderBy(String fieldName, String direction) {
this.orderByField = String.escapeSingleQuotes(fieldName);
this.sortDirection = direction;
return this;
}
public SOQLBuilder limitTo(Integer count) {
this.limitCount = count;
return this;
}
public String build() {
if (fields.isEmpty()) {
fields.add('Id');
}
String query = 'SELECT ' + String.join(fields, ', ');
query += ' FROM ' + String.escapeSingleQuotes(objectName);
if (!conditions.isEmpty()) {
query += ' WHERE ' + String.join(conditions, ' AND ');
}
if (orderByField != null) {
query += ' ORDER BY ' + orderByField + ' ' + sortDirection;
}
if (limitCount != null) {
query += ' LIMIT ' + limitCount;
}
return query;
}
public List<SObject> execute() {
return Database.query(build());
}
}
Usage reads like natural language:
List<SObject> results = new SOQLBuilder('Account')
.selectFields(new List<String>{ 'Id', 'Name', 'Industry', 'AnnualRevenue' })
.whereCondition('Industry = \'Technology\'')
.whereCondition('AnnualRevenue > 1000000')
.orderBy('AnnualRevenue', 'DESC')
.limitTo(50)
.execute();
Each method returns this, which enables the fluent chaining. The builder accumulates state, and build() assembles the final string. This is far more readable than concatenating a SOQL string with a dozen if-statements.
3. Factory Pattern
The Factory pattern encapsulates object creation behind a method. Instead of the caller deciding which concrete class to instantiate, the factory decides based on some input. This decouples the caller from the concrete implementation.
When to Use in Salesforce
- Trigger handler frameworks where a factory returns the correct handler for a given SObject type.
- Service classes where the implementation varies based on record type, org configuration, or feature flags.
- Integration adapters where different external systems require different processing classes.
Apex Example: TriggerHandlerFactory
public interface ITriggerHandler {
void beforeInsert(List<SObject> newRecords);
void afterInsert(List<SObject> newRecords);
void beforeUpdate(Map<Id, SObject> oldMap, List<SObject> newRecords);
void afterUpdate(Map<Id, SObject> oldMap, List<SObject> newRecords);
void beforeDelete(List<SObject> oldRecords);
void afterDelete(List<SObject> oldRecords);
}
public class TriggerHandlerFactory {
private static final Map<String, Type> handlerTypeMap = new Map<String, Type>{
'Account' => AccountTriggerHandler.class,
'Contact' => ContactTriggerHandler.class,
'Opportunity' => OpportunityTriggerHandler.class,
'Case' => CaseTriggerHandler.class
};
public static ITriggerHandler createHandler(String objectName) {
Type handlerType = handlerTypeMap.get(objectName);
if (handlerType == null) {
throw new TriggerHandlerException(
'No handler registered for: ' + objectName
);
}
return (ITriggerHandler) handlerType.newInstance();
}
public class TriggerHandlerException extends Exception {}
}
A single generic trigger can now use the factory:
// In any trigger
trigger UniversalTrigger on Account (before insert, after insert,
before update, after update, before delete, after delete) {
ITriggerHandler handler = TriggerHandlerFactory.createHandler('Account');
if (Trigger.isBefore && Trigger.isInsert) {
handler.beforeInsert(Trigger.new);
} else if (Trigger.isAfter && Trigger.isInsert) {
handler.afterInsert(Trigger.new);
}
// ... other operations
}
Adding a new object is a one-line change in the map. No switch statements scattered across the codebase.
4. Abstract Factory Pattern
The Abstract Factory pattern provides an interface for creating families of related objects without specifying their concrete classes. It is a factory of factories. Where the simple Factory creates one type of object, the Abstract Factory creates a coordinated set of objects that work together.
When to Use in Salesforce
- Multi-region or multi-brand orgs where the notification system, discount engine, and approval process differ by business unit.
- Applications that need to swap between a live integration layer and a mock integration layer for testing.
Apex Example
public interface INotificationService {
void send(String recipientId, String message);
}
public interface IDiscountEngine {
Decimal calculateDiscount(Opportunity opp);
}
// Abstract Factory interface
public interface IBusinessUnitFactory {
INotificationService createNotificationService();
IDiscountEngine createDiscountEngine();
}
// Concrete factory for North America
public class NorthAmericaFactory implements IBusinessUnitFactory {
public INotificationService createNotificationService() {
return new EmailNotificationService();
}
public IDiscountEngine createDiscountEngine() {
return new TieredDiscountEngine();
}
}
// Concrete factory for Europe
public class EuropeFactory implements IBusinessUnitFactory {
public INotificationService createNotificationService() {
return new SMSNotificationService();
}
public IDiscountEngine createDiscountEngine() {
return new FlatDiscountEngine();
}
}
// Client code that works with any factory
public class OrderProcessor {
private INotificationService notifier;
private IDiscountEngine discounter;
public OrderProcessor(IBusinessUnitFactory factory) {
this.notifier = factory.createNotificationService();
this.discounter = factory.createDiscountEngine();
}
public void processOrder(Opportunity opp) {
Decimal discount = discounter.calculateDiscount(opp);
opp.Discount_Percent__c = discount;
update opp;
notifier.send(opp.OwnerId, 'Order processed with ' + discount + '% discount.');
}
}
The OrderProcessor does not know whether it is working with North American services or European services. It only knows the interfaces. Swapping behavior is as simple as passing a different factory.
5. Prototype Pattern
The Prototype pattern creates new objects by cloning existing ones. In many languages, implementing clone methods requires ceremony. In Apex, SObjects have a built-in clone() method, which makes this pattern almost free.
When to Use in Salesforce
- Creating template records that are cloned and customized for each new entry.
- Duplicating complex records with child relationships.
- Setting up test data by cloning a base record and varying one or two fields.
Apex Example
public class OpportunityPrototype {
private Opportunity template;
public OpportunityPrototype(Opportunity template) {
this.template = template;
}
// clone(preserveId, isDeepClone, preserveReadOnly, preserveAutoNumber)
public Opportunity createClone() {
return template.clone(false, true, false, false);
}
public Opportunity createCloneWithOverrides(Map<String, Object> overrides) {
Opportunity cloned = template.clone(false, true, false, false);
for (String field : overrides.keySet()) {
cloned.put(field, overrides.get(field));
}
return cloned;
}
}
Usage:
Opportunity baseOpp = new Opportunity(
StageName = 'Prospecting',
CloseDate = Date.today().addDays(30),
Type = 'New Customer',
LeadSource = 'Web'
);
OpportunityPrototype proto = new OpportunityPrototype(baseOpp);
// Create many variations from the same template
Opportunity opp1 = proto.createCloneWithOverrides(new Map<String, Object>{
'Name' => 'Acme Deal',
'Amount' => 50000,
'AccountId' => acmeAccountId
});
Opportunity opp2 = proto.createCloneWithOverrides(new Map<String, Object>{
'Name' => 'Globex Deal',
'Amount' => 75000,
'AccountId' => globexAccountId
});
The clone() parameters are worth memorizing. The first boolean controls whether the Id is preserved. The second controls deep cloning for relationship fields. In most cases you want clone(false, true, false, false) — a deep clone with a fresh Id.
Structural Patterns
Structural patterns deal with how classes and objects are composed to form larger structures. They help you build flexible architectures where components can be swapped, wrapped, or combined without rewriting the classes that use them.
6. Facade Pattern
The Facade pattern provides a simplified interface to a complex subsystem. Instead of forcing client code to interact with five different classes, the Facade exposes a single class with a few clean methods that orchestrate everything behind the scenes.
When to Use in Salesforce
- Order processing that involves inventory checks, discount calculations, tax computation, record creation, and notification — all behind one method call.
- Onboarding flows where multiple objects and external systems need to be coordinated.
- Any situation where a Lightning component or Flow needs to call a single Apex method that does many things internally.
Apex Example: OrderFacade
public class OrderFacade {
private InventoryService inventoryService;
private DiscountService discountService;
private TaxService taxService;
private NotificationService notificationService;
public OrderFacade() {
this.inventoryService = new InventoryService();
this.discountService = new DiscountService();
this.taxService = new TaxService();
this.notificationService = new NotificationService();
}
// One simple method hides the complexity
public Order__c placeOrder(Id accountId, List<OrderLineItem> items) {
// Step 1: Validate inventory
inventoryService.validateAvailability(items);
// Step 2: Calculate discounts
Decimal subtotal = discountService.applyDiscounts(accountId, items);
// Step 3: Calculate tax
Decimal tax = taxService.calculateTax(accountId, subtotal);
// Step 4: Create the order record
Order__c order = new Order__c(
Account__c = accountId,
Subtotal__c = subtotal,
Tax__c = tax,
Total__c = subtotal + tax,
Status__c = 'Confirmed'
);
insert order;
// Step 5: Create line items
createLineItems(order.Id, items);
// Step 6: Reserve inventory
inventoryService.reserve(order.Id, items);
// Step 7: Notify
notificationService.sendOrderConfirmation(order.Id);
return order;
}
private void createLineItems(Id orderId, List<OrderLineItem> items) {
List<Order_Line__c> lines = new List<Order_Line__c>();
for (OrderLineItem item : items) {
lines.add(new Order_Line__c(
Order__c = orderId,
Product__c = item.productId,
Quantity__c = item.quantity,
Unit_Price__c = item.unitPrice
));
}
insert lines;
}
public class OrderLineItem {
public Id productId;
public Integer quantity;
public Decimal unitPrice;
}
}
A Lightning component only needs to call OrderFacade.placeOrder(). It does not know or care about inventory services, tax calculations, or notifications. That is the power of the Facade.
7. Composite Pattern
The Composite pattern lets you treat individual objects and collections of objects uniformly. It builds tree structures where a branch node and a leaf node share the same interface.
When to Use in Salesforce
- Permission systems where a permission group contains both individual permissions and nested groups.
- Approval hierarchies where an approval step can be a single approver or a group of approvers.
- Reporting structures where a territory can contain sub-territories.
Apex Example
public interface IApprovalComponent {
Boolean approve(Opportunity opp);
String getName();
}
// Leaf node — a single approver
public class SingleApprover implements IApprovalComponent {
private String approverName;
private Decimal approvalLimit;
public SingleApprover(String name, Decimal limit) {
this.approverName = name;
this.approvalLimit = limit;
}
public Boolean approve(Opportunity opp) {
return opp.Amount <= approvalLimit;
}
public String getName() {
return approverName;
}
}
// Composite node — a group that contains other components
public class ApprovalGroup implements IApprovalComponent {
private String groupName;
private List<IApprovalComponent> members;
public ApprovalGroup(String name) {
this.groupName = name;
this.members = new List<IApprovalComponent>();
}
public void addMember(IApprovalComponent member) {
members.add(member);
}
// All members must approve
public Boolean approve(Opportunity opp) {
for (IApprovalComponent member : members) {
if (!member.approve(opp)) {
return false;
}
}
return true;
}
public String getName() {
return groupName;
}
}
Usage:
SingleApprover manager = new SingleApprover('Sales Manager', 100000);
SingleApprover director = new SingleApprover('Sales Director', 500000);
SingleApprover vp = new SingleApprover('VP of Sales', 1000000);
ApprovalGroup executiveGroup = new ApprovalGroup('Executive Approval');
executiveGroup.addMember(director);
executiveGroup.addMember(vp);
ApprovalGroup fullChain = new ApprovalGroup('Full Approval Chain');
fullChain.addMember(manager);
fullChain.addMember(executiveGroup); // Nesting groups inside groups
Boolean approved = fullChain.approve(someOpportunity);
The client code calls approve() the same way regardless of whether it is talking to a single approver or a nested group of groups.
8. Adapter Pattern
The Adapter pattern converts one interface into another that a client expects. It acts as a translator between two incompatible interfaces.
When to Use in Salesforce
- Wrapping responses from external APIs into Salesforce-friendly formats.
- Making a third-party package class conform to your internal interface.
- Converting between different data representations, such as XML to Apex objects.
Apex Example: Wrapping an External API Response
// Your internal interface
public interface IContactInfo {
String getFullName();
String getEmail();
String getPhone();
String getCompany();
}
// The external API returns data in a different shape
public class ExternalApiResponse {
public String first_name;
public String last_name;
public String email_address;
public String phone_number;
public String organization;
}
// The adapter bridges the gap
public class ExternalContactAdapter implements IContactInfo {
private ExternalApiResponse source;
public ExternalContactAdapter(ExternalApiResponse response) {
this.source = response;
}
public String getFullName() {
return source.first_name + ' ' + source.last_name;
}
public String getEmail() {
return source.email_address;
}
public String getPhone() {
return source.phone_number;
}
public String getCompany() {
return source.organization;
}
}
// Now your internal code works with any IContactInfo
public class ContactImporter {
public Contact importContact(IContactInfo info) {
return new Contact(
LastName = info.getFullName().substringAfter(' '),
FirstName = info.getFullName().substringBefore(' '),
Email = info.getEmail(),
Phone = info.getPhone(),
Company__c = info.getCompany()
);
}
}
When the external API changes its field names, you only update the adapter. Every class that uses IContactInfo remains untouched.
9. Bridge Pattern
The Bridge pattern separates an abstraction from its implementation so that the two can vary independently. Think of it as having two separate hierarchies — one for what you do and one for how you do it — connected by a bridge.
When to Use in Salesforce
- Notification systems where the message type (alert, reminder, escalation) and the delivery channel (email, Chatter, SMS, Platform Event) are independent dimensions.
- Reporting where the report format (summary, detail, chart) and the data source (SOQL, external API, aggregate) can be mixed and matched.
Apex Example
// Implementation hierarchy — the "how"
public interface IMessageChannel {
void deliver(String recipientId, String subject, String body);
}
public class EmailChannel implements IMessageChannel {
public void deliver(String recipientId, String subject, String body) {
Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
mail.setTargetObjectId(recipientId);
mail.setSubject(subject);
mail.setPlainTextBody(body);
mail.setSaveAsActivity(false);
Messaging.sendEmail(new List<Messaging.SingleEmailMessage>{ mail });
}
}
public class ChatterChannel implements IMessageChannel {
public void deliver(String recipientId, String subject, String body) {
FeedItem post = new FeedItem(
ParentId = recipientId,
Title = subject,
Body = body
);
insert post;
}
}
// Abstraction hierarchy — the "what"
public abstract class Notification {
protected IMessageChannel channel;
public Notification(IMessageChannel channel) {
this.channel = channel;
}
public abstract void send(String recipientId);
}
public class UrgentAlert extends Notification {
private String alertMessage;
public UrgentAlert(IMessageChannel channel, String message) {
super(channel);
this.alertMessage = message;
}
public override void send(String recipientId) {
channel.deliver(recipientId, 'URGENT ALERT', alertMessage);
}
}
public class WeeklyReminder extends Notification {
private String reminderText;
public WeeklyReminder(IMessageChannel channel, String text) {
super(channel);
this.reminderText = text;
}
public override void send(String recipientId) {
channel.deliver(recipientId, 'Weekly Reminder', reminderText);
}
}
Now you can send an urgent alert via email or Chatter, and a weekly reminder via email or Chatter, without an explosion of subclasses:
IMessageChannel email = new EmailChannel();
IMessageChannel chatter = new ChatterChannel();
Notification urgentByEmail = new UrgentAlert(email, 'Server is down.');
Notification urgentByChatter = new UrgentAlert(chatter, 'Server is down.');
urgentByEmail.send(userId);
urgentByChatter.send(userId);
10. Decorator Pattern
The Decorator pattern adds behavior to an object dynamically, without modifying its class. You wrap the original object in a decorator that implements the same interface and adds something extra before or after delegating to the wrapped object.
When to Use in Salesforce
- Adding logging, timing, or error handling around a service call without modifying the service itself.
- Layering additional validation on top of existing validation logic.
- Conditionally enhancing behavior based on feature flags.
Apex Example
public interface ILeadScorer {
Integer score(Lead lead);
}
// Base scorer
public class BasicLeadScorer implements ILeadScorer {
public Integer score(Lead lead) {
Integer total = 0;
if (lead.Company != null) total += 10;
if (lead.Email != null) total += 10;
if (lead.Phone != null) total += 5;
return total;
}
}
// Decorator that adds industry bonus
public class IndustryBonusDecorator implements ILeadScorer {
private ILeadScorer wrapped;
private Set<String> highValueIndustries;
public IndustryBonusDecorator(ILeadScorer scorer, Set<String> industries) {
this.wrapped = scorer;
this.highValueIndustries = industries;
}
public Integer score(Lead lead) {
Integer baseScore = wrapped.score(lead);
if (lead.Industry != null && highValueIndustries.contains(lead.Industry)) {
baseScore += 20;
}
return baseScore;
}
}
// Decorator that adds logging
public class LoggingLeadScorer implements ILeadScorer {
private ILeadScorer wrapped;
public LoggingLeadScorer(ILeadScorer scorer) {
this.wrapped = scorer;
}
public Integer score(Lead lead) {
Integer result = wrapped.score(lead);
System.debug('Lead ' + lead.LastName + ' scored: ' + result);
return result;
}
}
Stack decorators like layers:
ILeadScorer scorer = new LoggingLeadScorer(
new IndustryBonusDecorator(
new BasicLeadScorer(),
new Set<String>{ 'Technology', 'Finance' }
)
);
Integer finalScore = scorer.score(someLead);
// BasicLeadScorer runs first, IndustryBonusDecorator adds bonus, LoggingLeadScorer logs
11. Flyweight Pattern
The Flyweight pattern shares common state among many objects to reduce memory usage. Instead of each object storing its own copy of data that is the same across instances, the shared data lives in a flyweight object that everyone references.
When to Use in Salesforce
- Processing thousands of records where each record references the same picklist metadata, record type info, or permission mappings.
- Reducing heap size in batch jobs by sharing reference data.
Apex Example
public class RecordTypeCache {
private static Map<String, Map<String, Id>> cache = new Map<String, Map<String, Id>>();
// Shared state — loaded once, used by all callers
public static Id getRecordTypeId(String objectName, String developerName) {
if (!cache.containsKey(objectName)) {
Map<String, Id> rtMap = new Map<String, Id>();
for (RecordType rt : [
SELECT Id, DeveloperName
FROM RecordType
WHERE SObjectType = :objectName
]) {
rtMap.put(rt.DeveloperName, rt.Id);
}
cache.put(objectName, rtMap);
}
return cache.get(objectName).get(developerName);
}
}
Every class that needs a Record Type Id calls RecordTypeCache.getRecordTypeId(). The query runs once per object type per transaction. Without this pattern, a trigger processing 200 records might run the same Record Type query 200 times.
This is essentially a combination of Flyweight and Singleton. You will see these two patterns paired frequently in Apex because of governor limits.
12. Proxy Pattern
The Proxy pattern provides a surrogate or placeholder for another object to control access to it. The proxy sits between the client and the real object, adding a layer of control — such as lazy loading, access checking, or caching.
When to Use in Salesforce
- Lazy-loading expensive data only when it is actually needed.
- Adding permission checks before allowing access to a service.
- Caching callout results to avoid redundant HTTP requests.
Apex Example
public interface IExternalService {
Map<String, Object> fetchData(String endpoint);
}
// Real service that makes the callout
public class RealExternalService implements IExternalService {
public Map<String, Object> fetchData(String endpoint) {
HttpRequest req = new HttpRequest();
req.setEndpoint(endpoint);
req.setMethod('GET');
Http http = new Http();
HttpResponse res = http.send(req);
return (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
}
}
// Proxy adds caching and access control
public class ExternalServiceProxy implements IExternalService {
private IExternalService realService;
private Map<String, Map<String, Object>> responseCache;
public ExternalServiceProxy() {
this.realService = new RealExternalService();
this.responseCache = new Map<String, Map<String, Object>>();
}
public Map<String, Object> fetchData(String endpoint) {
// Access control
if (!FeatureManagement.checkPermission('External_API_Access')) {
throw new SecurityException('Insufficient permissions for external API.');
}
// Caching
if (responseCache.containsKey(endpoint)) {
return responseCache.get(endpoint);
}
Map<String, Object> result = realService.fetchData(endpoint);
responseCache.put(endpoint, result);
return result;
}
public class SecurityException extends Exception {}
}
Callers use ExternalServiceProxy exactly like they would use RealExternalService. The proxy transparently adds caching and security without the caller knowing.
Behavioral Patterns
Behavioral patterns are about communication between objects. They define how objects interact, distribute responsibility, and delegate work.
13. Observer Pattern
The Observer pattern defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified automatically. In traditional programming you build this with callback lists. In Salesforce, Platform Events give you this for free.
When to Use in Salesforce
- Decoupling subsystems so that a change in one area automatically triggers actions in other areas without direct coupling.
- Cross-object automation where an event in one context needs to fan out to multiple listeners.
- Integration scenarios where external systems need to react to Salesforce changes.
Apex Example: Platform Events as Observer
// Publishing an event (the Subject)
public class OrderEventPublisher {
public static void publishOrderCompleted(Id orderId, Decimal amount) {
Order_Event__e event = new Order_Event__e(
Order_Id__c = orderId,
Amount__c = amount,
Event_Type__c = 'ORDER_COMPLETED'
);
Database.SaveResult result = EventBus.publish(event);
if (!result.isSuccess()) {
for (Database.Error err : result.getErrors()) {
System.debug(LoggingLevel.ERROR, 'Publish error: ' + err.getMessage());
}
}
}
}
// Observer 1: Update inventory (trigger on Platform Event)
trigger OrderEventInventoryTrigger on Order_Event__e (after insert) {
List<Id> orderIds = new List<Id>();
for (Order_Event__e evt : Trigger.new) {
if (evt.Event_Type__c == 'ORDER_COMPLETED') {
orderIds.add(evt.Order_Id__c);
}
}
if (!orderIds.isEmpty()) {
InventoryService.reduceStock(orderIds);
}
}
// Observer 2: Send confirmation email (separate trigger or flow)
trigger OrderEventNotificationTrigger on Order_Event__e (after insert) {
for (Order_Event__e evt : Trigger.new) {
if (evt.Event_Type__c == 'ORDER_COMPLETED') {
NotificationService.sendOrderConfirmation(evt.Order_Id__c);
}
}
}
The publisher does not know how many observers exist. You can add new observers by creating new Platform Event triggers without modifying the publisher. This is true decoupling.
You can also implement Observer without Platform Events for in-transaction use:
public interface IEventObserver {
void onEvent(String eventType, Map<String, Object> payload);
}
public class EventBroker {
private static Map<String, List<IEventObserver>> subscribers =
new Map<String, List<IEventObserver>>();
public static void subscribe(String eventType, IEventObserver observer) {
if (!subscribers.containsKey(eventType)) {
subscribers.put(eventType, new List<IEventObserver>());
}
subscribers.get(eventType).add(observer);
}
public static void publish(String eventType, Map<String, Object> payload) {
if (subscribers.containsKey(eventType)) {
for (IEventObserver observer : subscribers.get(eventType)) {
observer.onEvent(eventType, payload);
}
}
}
}
14. Template Method Pattern
The Template Method pattern defines the skeleton of an algorithm in a base class, letting subclasses override specific steps without changing the overall structure. The base class calls abstract or virtual methods at predefined hook points.
When to Use in Salesforce
- Trigger handler frameworks where the execution order (before validation, field defaulting, after DML, notifications) is fixed, but each object’s logic is different.
- Batch jobs that follow the same structure (load, process, log) but process different data.
- Integration handlers where the sequence (authenticate, call, parse, save) is the same but the details differ.
Apex Example
public abstract class BaseTriggerHandler {
public void execute() {
if (Trigger.isBefore) {
validateRecords();
applyDefaults();
applyBusinessRules();
}
if (Trigger.isAfter) {
createRelatedRecords();
sendNotifications();
publishEvents();
}
}
// Subclasses must implement these
protected abstract void validateRecords();
protected abstract void applyBusinessRules();
// Subclasses can optionally override these — default is no-op
protected virtual void applyDefaults() {}
protected virtual void createRelatedRecords() {}
protected virtual void sendNotifications() {}
protected virtual void publishEvents() {}
}
public class OpportunityHandler extends BaseTriggerHandler {
protected override void validateRecords() {
for (Opportunity opp : (List<Opportunity>) Trigger.new) {
if (opp.Amount == null || opp.Amount < 0) {
opp.addError('Amount must be a positive number.');
}
}
}
protected override void applyBusinessRules() {
for (Opportunity opp : (List<Opportunity>) Trigger.new) {
if (opp.Amount > 1000000) {
opp.Requires_VP_Approval__c = true;
}
}
}
protected override void sendNotifications() {
// Custom notification logic for large opportunities
List<Id> largeOppOwners = new List<Id>();
for (Opportunity opp : (List<Opportunity>) Trigger.new) {
if (opp.Amount > 500000) {
largeOppOwners.add(opp.OwnerId);
}
}
if (!largeOppOwners.isEmpty()) {
NotificationService.notifyManagers(largeOppOwners);
}
}
}
The execution order is locked in the base class. Each SObject handler only fills in the steps that matter for that object. The virtual methods with empty default implementations mean handlers only override what they need.
15. Strategy Pattern
The Strategy pattern defines a family of algorithms, encapsulates each one behind an interface, and makes them interchangeable. The client selects the algorithm at runtime.
When to Use in Salesforce
- Discount calculations where the algorithm varies by customer tier, product category, or time of year.
- Lead assignment where different routing rules apply based on lead source or geography.
- Data export where the format (CSV, JSON, XML) is selected by the user.
Apex Example
public interface IDiscountStrategy {
Decimal calculateDiscount(Decimal amount, Integer quantity);
}
public class NoDiscount implements IDiscountStrategy {
public Decimal calculateDiscount(Decimal amount, Integer quantity) {
return 0;
}
}
public class PercentageDiscount implements IDiscountStrategy {
private Decimal percentage;
public PercentageDiscount(Decimal percentage) {
this.percentage = percentage;
}
public Decimal calculateDiscount(Decimal amount, Integer quantity) {
return amount * (percentage / 100);
}
}
public class VolumeDiscount implements IDiscountStrategy {
public Decimal calculateDiscount(Decimal amount, Integer quantity) {
if (quantity >= 100) return amount * 0.20;
if (quantity >= 50) return amount * 0.10;
if (quantity >= 10) return amount * 0.05;
return 0;
}
}
public class TieredDiscount implements IDiscountStrategy {
public Decimal calculateDiscount(Decimal amount, Integer quantity) {
Decimal discount = 0;
if (amount > 100000) discount += amount * 0.15;
else if (amount > 50000) discount += amount * 0.10;
else if (amount > 10000) discount += amount * 0.05;
return discount;
}
}
// Context class that uses the strategy
public class PricingEngine {
private IDiscountStrategy strategy;
public PricingEngine(IDiscountStrategy strategy) {
this.strategy = strategy;
}
public void setStrategy(IDiscountStrategy strategy) {
this.strategy = strategy;
}
public Decimal computeFinalPrice(Decimal basePrice, Integer quantity) {
Decimal discount = strategy.calculateDiscount(basePrice, quantity);
return basePrice - discount;
}
}
Usage:
PricingEngine engine = new PricingEngine(new VolumeDiscount());
Decimal price1 = engine.computeFinalPrice(10000, 75); // 10% off
engine.setStrategy(new PercentageDiscount(25));
Decimal price2 = engine.computeFinalPrice(10000, 75); // flat 25% off
The caller can swap the discount algorithm at any time without changing the PricingEngine class.
16. Chain of Responsibility
The Chain of Responsibility pattern passes a request along a chain of handlers. Each handler decides whether to process the request or pass it to the next handler in the chain.
When to Use in Salesforce
- Validation chains where each validator checks one rule and can short-circuit or pass to the next.
- Approval routing where a request escalates through a chain of approvers.
- Data enrichment pipelines where each step enriches the record and passes it along.
Apex Example: Validation Chain
public abstract class ValidationHandler {
private ValidationHandler nextHandler;
public ValidationHandler setNext(ValidationHandler next) {
this.nextHandler = next;
return next; // enables chaining
}
public ValidationResult handle(Opportunity opp) {
ValidationResult result = validate(opp);
if (!result.isValid) {
return result; // stop the chain
}
if (nextHandler != null) {
return nextHandler.handle(opp);
}
return result; // all validations passed
}
protected abstract ValidationResult validate(Opportunity opp);
public class ValidationResult {
public Boolean isValid;
public String message;
public ValidationResult(Boolean valid, String msg) {
this.isValid = valid;
this.message = msg;
}
}
}
public class AmountValidator extends ValidationHandler {
protected override ValidationResult validate(Opportunity opp) {
if (opp.Amount == null || opp.Amount <= 0) {
return new ValidationResult(false, 'Amount must be positive.');
}
return new ValidationResult(true, 'Amount OK.');
}
}
public class CloseDateValidator extends ValidationHandler {
protected override ValidationResult validate(Opportunity opp) {
if (opp.CloseDate == null || opp.CloseDate < Date.today()) {
return new ValidationResult(false, 'Close date must be in the future.');
}
return new ValidationResult(true, 'Close date OK.');
}
}
public class StageValidator extends ValidationHandler {
protected override ValidationResult validate(Opportunity opp) {
Set<String> validStages = new Set<String>{
'Prospecting', 'Qualification', 'Proposal', 'Negotiation', 'Closed Won'
};
if (!validStages.contains(opp.StageName)) {
return new ValidationResult(false, 'Invalid stage: ' + opp.StageName);
}
return new ValidationResult(true, 'Stage OK.');
}
}
Build the chain and run it:
ValidationHandler chain = new AmountValidator();
chain
.setNext(new CloseDateValidator())
.setNext(new StageValidator());
ValidationHandler.ValidationResult result = chain.handle(someOpportunity);
if (!result.isValid) {
someOpportunity.addError(result.message);
}
Adding or reordering validators is trivial. You just change how the chain is wired.
17. Command Pattern
The Command pattern encapsulates a request as an object. This lets you parameterize methods with different requests, queue them, log them, and support undo operations.
When to Use in Salesforce
- Action queues where DML operations are collected and executed in a controlled sequence.
- Undo functionality where you need to reverse a set of operations.
- Scheduled operations where commands are stored and executed later.
Apex Example
public interface ICommand {
void execute();
void undo();
}
public class CreateRecordCommand implements ICommand {
private SObject record;
private Id createdId;
public CreateRecordCommand(SObject record) {
this.record = record;
}
public void execute() {
insert record;
createdId = record.Id;
}
public void undo() {
if (createdId != null) {
delete [SELECT Id FROM SObject WHERE Id = :createdId];
}
}
}
public class UpdateFieldCommand implements ICommand {
private Id recordId;
private String fieldName;
private Object newValue;
private Object oldValue;
private String objectType;
public UpdateFieldCommand(String objectType, Id recordId, String field,
Object oldVal, Object newVal) {
this.objectType = objectType;
this.recordId = recordId;
this.fieldName = field;
this.oldValue = oldVal;
this.newValue = newVal;
}
public void execute() {
SObject record = Schema.getGlobalDescribe()
.get(objectType)
.newSObject(recordId);
record.put(fieldName, newValue);
update record;
}
public void undo() {
SObject record = Schema.getGlobalDescribe()
.get(objectType)
.newSObject(recordId);
record.put(fieldName, oldValue);
update record;
}
}
// Command invoker that manages execution and undo
public class CommandInvoker {
private List<ICommand> history = new List<ICommand>();
public void executeCommand(ICommand command) {
command.execute();
history.add(command);
}
public void undoLast() {
if (!history.isEmpty()) {
ICommand lastCommand = history.remove(history.size() - 1);
lastCommand.undo();
}
}
public void undoAll() {
for (Integer i = history.size() - 1; i >= 0; i--) {
history.get(i).undo();
}
history.clear();
}
}
18. Additional Behavioral Patterns
The remaining Gang of Four behavioral patterns appear less frequently in Apex, but each has its place. Here is a brief look at when they apply.
Iterator
Apex collections already support for loops and the Iterator interface. You would implement a custom iterator when you need to traverse a complex data structure — for example, iterating over a tree of Account hierarchies or paginating through a large dataset from an external API.
public class AccountHierarchyIterator implements Iterator<Account> {
private List<Account> flatList;
private Integer currentIndex;
public AccountHierarchyIterator(List<Account> accounts) {
this.flatList = accounts;
this.currentIndex = 0;
}
public Boolean hasNext() {
return currentIndex < flatList.size();
}
public Account next() {
if (!hasNext()) {
throw new NoSuchElementException('No more accounts.');
}
return flatList[currentIndex++];
}
}
Mediator
The Mediator centralizes communication between multiple objects so they do not reference each other directly. In Salesforce, an orchestration service that coordinates between AccountService, ContactService, and OpportunityService is a mediator. Platform Events also serve as a mediator between decoupled subsystems.
Memento
The Memento pattern captures and restores an object’s state. In Apex, you can serialize an SObject to JSON before modifying it, then deserialize to restore. This is useful for preview or rollback features in Lightning components.
// Save state
String memento = JSON.serialize(myRecord);
// Modify record
myRecord.Status__c = 'Processed';
// Restore state if needed
myRecord = (Account) JSON.deserialize(memento, Account.class);
State
The State pattern allows an object to change its behavior when its internal state changes. In Salesforce, this maps naturally to record statuses. Instead of long if-else chains checking Status__c, you create a state class for each status that defines the allowed transitions and behaviors.
Visitor
The Visitor pattern lets you add new operations to a class hierarchy without modifying the classes. In Apex, this is useful for reporting or exporting logic. A visitor can traverse a collection of different SObject types, extracting or transforming data from each without changing the SObject wrappers.
Project: Combining Patterns for a Dynamic Rule Engine
Let us bring together a creational, structural, and behavioral pattern into a single cohesive class. We will build a dynamic rule engine that evaluates business rules against records. It uses the Singleton pattern (creational) to manage rule configuration, the Composite pattern (structural) to build complex rule trees, and the Strategy pattern (behavioral) to allow interchangeable evaluation logic.
The Rule Interface and Strategies
public interface IRule {
Boolean evaluate(SObject record);
String getRuleName();
}
// Leaf rule: evaluates a single field condition
public class FieldRule implements IRule {
private String fieldName;
private String operator;
private Object value;
public FieldRule(String fieldName, String operator, Object value) {
this.fieldName = fieldName;
this.operator = operator;
this.value = value;
}
public Boolean evaluate(SObject record) {
Object fieldValue = record.get(fieldName);
switch on operator {
when 'EQUALS' {
return fieldValue == value;
}
when 'NOT_EQUALS' {
return fieldValue != value;
}
when 'GREATER_THAN' {
return (Decimal) fieldValue > (Decimal) value;
}
when 'LESS_THAN' {
return (Decimal) fieldValue < (Decimal) value;
}
when 'CONTAINS' {
return fieldValue != null
&& String.valueOf(fieldValue).contains(String.valueOf(value));
}
when 'IS_NULL' {
return fieldValue == null;
}
when 'IS_NOT_NULL' {
return fieldValue != null;
}
when else {
throw new RuleEngineException('Unknown operator: ' + operator);
}
}
}
public String getRuleName() {
return fieldName + ' ' + operator + ' ' + String.valueOf(value);
}
}
Composite Rules
// AND composite — all child rules must pass
public class AndCompositeRule implements IRule {
private String name;
private List<IRule> rules;
public AndCompositeRule(String name) {
this.name = name;
this.rules = new List<IRule>();
}
public AndCompositeRule addRule(IRule rule) {
rules.add(rule);
return this;
}
public Boolean evaluate(SObject record) {
for (IRule rule : rules) {
if (!rule.evaluate(record)) {
return false;
}
}
return true;
}
public String getRuleName() {
return name;
}
}
// OR composite — at least one child rule must pass
public class OrCompositeRule implements IRule {
private String name;
private List<IRule> rules;
public OrCompositeRule(String name) {
this.name = name;
this.rules = new List<IRule>();
}
public OrCompositeRule addRule(IRule rule) {
rules.add(rule);
return this;
}
public Boolean evaluate(SObject record) {
for (IRule rule : rules) {
if (rule.evaluate(record)) {
return true;
}
}
return false;
}
public String getRuleName() {
return name;
}
}
Singleton Rule Engine
public class RuleEngine {
private static RuleEngine instance;
private Map<String, IRule> ruleRegistry;
private RuleEngine() {
ruleRegistry = new Map<String, IRule>();
loadRulesFromConfig();
}
public static RuleEngine getInstance() {
if (instance == null) {
instance = new RuleEngine();
}
return instance;
}
private void loadRulesFromConfig() {
// Load rule definitions from Custom Metadata
for (Rule_Definition__mdt ruleDef : Rule_Definition__mdt.getAll().values()) {
if (ruleDef.Is_Active__c) {
IRule rule = buildRuleFromMetadata(ruleDef);
ruleRegistry.put(ruleDef.DeveloperName, rule);
}
}
}
private IRule buildRuleFromMetadata(Rule_Definition__mdt def) {
return new FieldRule(
def.Field_API_Name__c,
def.Operator__c,
def.Value__c
);
}
// Register rules programmatically
public void registerRule(String name, IRule rule) {
ruleRegistry.put(name, rule);
}
// Evaluate a single named rule
public Boolean evaluateRule(String ruleName, SObject record) {
IRule rule = ruleRegistry.get(ruleName);
if (rule == null) {
throw new RuleEngineException('Rule not found: ' + ruleName);
}
return rule.evaluate(record);
}
// Evaluate all registered rules and return results
public Map<String, Boolean> evaluateAllRules(SObject record) {
Map<String, Boolean> results = new Map<String, Boolean>();
for (String ruleName : ruleRegistry.keySet()) {
results.put(ruleName, ruleRegistry.get(ruleName).evaluate(record));
}
return results;
}
// Evaluate rules against a collection of records
public Map<Id, Map<String, Boolean>> evaluateBulk(List<SObject> records) {
Map<Id, Map<String, Boolean>> allResults =
new Map<Id, Map<String, Boolean>>();
for (SObject record : records) {
allResults.put(record.Id, evaluateAllRules(record));
}
return allResults;
}
public class RuleEngineException extends Exception {}
}
Putting It All Together
// Build a composite rule: High-value tech opportunity
AndCompositeRule highValueTechRule = new AndCompositeRule('High Value Tech Opp');
highValueTechRule
.addRule(new FieldRule('Amount', 'GREATER_THAN', 100000))
.addRule(new FieldRule('StageName', 'NOT_EQUALS', 'Closed Lost'))
.addRule(
new OrCompositeRule('Tech Industry')
.addRule(new FieldRule('Industry__c', 'EQUALS', 'Technology'))
.addRule(new FieldRule('Industry__c', 'EQUALS', 'Software'))
);
// Register with the singleton engine
RuleEngine engine = RuleEngine.getInstance();
engine.registerRule('HighValueTech', highValueTechRule);
// Evaluate against records
List<Opportunity> opps = [
SELECT Id, Name, Amount, StageName, Industry__c
FROM Opportunity
WHERE CreatedDate = THIS_YEAR
];
Map<Id, Map<String, Boolean>> results = engine.evaluateBulk(opps);
for (Id oppId : results.keySet()) {
Map<String, Boolean> ruleResults = results.get(oppId);
if (ruleResults.get('HighValueTech') == true) {
System.debug('Opportunity ' + oppId + ' matches High Value Tech rule.');
}
}
This is the power of combining patterns. The Singleton ensures we load configuration once. The Composite lets us build arbitrarily complex rule trees from simple leaf rules. The Strategy pattern (via the IRule interface) means every rule — simple or composite — is interchangeable.
You can extend this engine by creating new IRule implementations for date comparisons, cross-object checks, or even callout-based rules. None of the existing code needs to change.
Choosing the Right Pattern
Not every problem needs a design pattern. Patterns add indirection, and indirection adds complexity. Here are some guidelines:
Use a pattern when:
- You find yourself writing the same structural code repeatedly.
- A future requirement change would force you to modify many classes.
- You need to decouple two parts of the system that are currently tangled.
- Governor limits demand that you share or cache expensive operations.
Skip the pattern when:
- The class has one responsibility and is unlikely to change.
- The extra abstraction makes the code harder to understand for your team.
- You are building a one-off script or data fix that will be deleted next week.
The patterns that appear most frequently in production Salesforce orgs are Singleton, Factory, Facade, Strategy, and Template Method. Master those five first. Then reach for the others when you encounter the specific problems they solve.
Summary
We covered eighteen design patterns from the Gang of Four catalog and showed how each one applies to Salesforce Apex development.
Creational patterns control how objects are created. Singleton ensures one instance per transaction. Builder creates complex objects step by step. Factory encapsulates which class to instantiate. Abstract Factory produces families of related objects. Prototype clones existing objects.
Structural patterns control how objects are composed. Facade simplifies complex subsystems. Composite builds trees of uniform components. Adapter bridges incompatible interfaces. Bridge separates abstraction from implementation. Decorator layers behavior dynamically. Flyweight shares common state. Proxy controls access.
Behavioral patterns control how objects communicate. Observer notifies dependents of state changes. Template Method locks the algorithm skeleton while allowing step customization. Strategy makes algorithms interchangeable. Chain of Responsibility passes requests along a handler chain. Command encapsulates operations as objects.
We then combined Singleton, Composite, and Strategy into a dynamic rule engine that demonstrates how patterns work together in a real Salesforce context.
Design patterns are tools, not rules. Learn them so you can recognize the problems they solve. Apply them when they simplify your code. Skip them when they do not.
In Part 66, we will turn to Unit Testing and Apex Mocks — how to write tests that are fast, isolated, and meaningful, using mocking frameworks to decouple your tests from the database and external services. See you there.