Good software development focuses on building solutions that are scalable, maintainable, and resistant to change. Whether you’re building a new functionality or maintaining your current codebase, your code will require a certain level of structure and clarity.
Design patterns provide solutions to common design challenges that a developer might face. They help avoid code duplication, improve readability and testability, and reduce overall code complexity. In this article, I will walk you through the four main design patterns every Salesforce Developer should know. I will also provide practical use cases along with Apex examples.
1. Singleton Pattern
What It Is
Singleton Pattern ensures that a class only has one instance throughout a single transaction, providing a global access point to it.
Salesforce Use Case
Loading configuration or custom metadata values that you want to retrieve only once per transaction.
Example
public class AppConfig {
private static AppConfig instance; //Static variable that holds the single instance
public String apiKey;
public String endpoint;
//Constructor declared as private to prevent direct instantiation
private AppConfig() {
//Load custom metadata only once from the constructor and assign the API Key value to apiKey
App_Settings__mdt setting = [SELECT API_Key__c, Endpoint__c FROM App_Settings__mdt LIMIT 1];
apiKey = setting.API_Key__c;
endpoint = setting.Endpoint__c;
}
//Global access point to the instance that other classes can call
public static AppConfig getInstance() {
if (instance == null) {
instance = new AppConfig(); //This allows us to initialize only on first call
}
return instance;
}
}
Usage
String key = AppConfig.getInstance().apiKey;
String url = AppConfig.getInstance().endpoint
Why It Matters
- Reduces redundant SOQL queries. Even though CMDT queries don’t count against the SOQL limit in Apex, they still consume CPU. Retrieving once keeps your code leaner.
- Keeps CPU in check. Each retrieval has a small overhead; a singleton minimizes repeated access, which is important in transactions that already push close to CPU limits.
- Balances with heap. Storing config in memory costs a tiny bit of heap space, but this trade-off is almost always favorable compared to the cumulative CPU hit of repeated lookups.
- Provides consistent configuration values across your codebase.
- Keeps logic centralized and reusable.
2. Factory Pattern
What It Is
This pattern encapsulates the object creation process. The factory returns different implementations of a shared interface, depending on the inputs or context provided by the user.
Salesforce Use Case
Assuming you need to instantiate different handlers (Example for payment, notifications, or approval logic) based on a specific set of criteria, such as record type or user role.
Example (With Steps)
- Define the shared interface PaymentProcessor.
public interface PaymentProcessor {
void process();
}
- Provide concrete implementations depending on the different logic required to handle each scenario.
public class PayPalProcessor implements PaymentProcessor {
public void process() {
// Add your paypal payment processing logic here
}
}
public class StripeProcessor implements PaymentProcessor {
public void process() {
// Add your Stripe payment processing logic here
}
}
- Create the factory class, where the getProcessor method returns a different PaymentProcessor handler depending on the type provided.
public class PaymentFactory {
public static PaymentProcessor getProcessor(String type) {
if (type == 'PayPal') return new PayPalProcessor();
if (type == 'Stripe') return new StripeProcessor();
throw new IllegalArgumentException('Unknown payment type');
}
}
Usage
PaymentProcessor processor = PaymentFactory.getProcessor('Stripe');
processor.process(); // Output: Processed payment via Stripe
In this code snippet, we were able to call the getProcessor method from PaymentFactory class and process the payment via Stripe.
Why It Matters
- Removes object creation logic from your core business code.
- Makes it easier to support new types by adding a new class.
- Supports better testability and inversion of control.
3. Strategy Pattern
What It Is
Encapsulates different algorithms or behaviors and makes them interchangeable at runtime.
Salesforce Use Case
When different products, customers, or regions require distinct business logic, such as discount calculations, tax rules, or risk scoring.
Example (With Steps)
- Define the strategy interface.
public interface DiscountStrategy {
Decimal apply(Decimal price);
}
- Introduce different discount algorithms that implement the DiscountStrategy interface.
public class TenPercentDiscount implements DiscountStrategy {
public Decimal apply(Decimal price) {
return price * 0.9;
}
}
public class Flat20Discount implements DiscountStrategy {
public Decimal apply(Decimal price) {
return price - 20;
}
}
- Create the context class where we will directly apply the discount strategy.
public class DiscountService {
private DiscountStrategy strategy;
public DiscountService(DiscountStrategy strategy) {
this.strategy = strategy;
}
public Decimal getDiscountedPrice(Decimal price) {
return strategy.apply(price);
}
}
Usage
PaymentProcessor processor = PaymentFactory.getProcessor('Stripe');
processor.process(); // Output: Processed payment via Stripe
service = new DiscountService(new Flat20Discount());
Decimal discounted2 = service.getDiscountedPrice(100); // Returns 80
Why It Matters
- Minimizes the need for complex if-else or switch statements.
- Makes business logic easier to update or extend.
- Enables unit testing of each strategy in isolation.
4. Unit of Work Pattern
What It Is
This pattern tracks changes to multiple different records and commits them together all at once, to reduce DML statements and ensure consistency.
Salesforce Use Case
In this scenario, when complex trigger logic or service layers where multiple inserts/updates to be grouped and executed efficiently.
Example
public class UnitOfWork {
private List<Account> newAccounts = new List<Account>();
private List<Contact> modifiedContacts = new List<Contact>();
public void registerNew(Account acc) {
newAccounts.add(acc);
}
public void registerModified(Contact con) {
modifiedContacts.add(con);
}
public void commit() {
if (!newAccounts.isEmpty()) {
insert newAccounts;
}
if (!modifiedContacts.isEmpty()) {
update modifiedContacts;
}
}
}
The above code snippet tracks new accounts to insert as well as existing contacts to update and commits all registered records at once via the commit() method.
Note: The example above is simplified to illustrate how the Unit of Work pattern works. In a real-world scenario, you would likely want to use a more scalable implementation that accepts generic sObject types, including error handling and managing rollbacks.
In most scenarios, you won’t need to build your own Unit of Work framework from scratch. You can find a lot of open-source implementations and managed libraries (such as Apex Enterprise Patterns) that already provide a robust implementation.
Usage
public class AccountHandler {
public static void process(List<Account> accounts) {
UnitOfWork uow = new UnitOfWork();
for (Account acc : accounts) {
acc.Name += ' - Processed';
uow.registerNew(acc);
Contact c = new Contact(LastName = acc.Name, AccountId = acc.Id);
uow.registerModified(c);
}
uow.commit(); // All DML operations happen here at once
}
}
Why It Matters
- Reduces scattered DML operations.
- Helps prevent hitting governor limits.
- Centralizes commit logic and improves maintainability.
Final Thoughts
Design patterns are practical shortcuts that allow you to write scalable Apex code.
These four patterns can help you organize your logic in one place, reduce duplication, and write scalable code in any Salesforce org.
Comments: