Building Scalable Apex Architecture: Design Patterns for Lightning Platform
As Salesforce applications grow in complexity, implementing proper design patterns becomes crucial for maintainability, testability, and scalability. This guide explores enterprise-grade patterns specifically tailored for the Lightning Platform.
1. Factory Pattern
The Factory pattern provides a way to create objects without specifying their exact class.
Implementation Example
public class AccountFactory {
public static Account createAccount(String recordType) {
switch on recordType {
when 'Enterprise' {
return new EnterpriseAccountBuilder().build();
}
when 'SMB' {
return new SMBAccountBuilder().build();
}
when else {
return new StandardAccountBuilder().build();
}
}
}
}
public abstract class AccountBuilder {
protected Account account;
public AccountBuilder() {
this.account = new Account();
}
public abstract Account build();
}
public class EnterpriseAccountBuilder extends AccountBuilder {
public override Account build() {
account.RecordTypeId = Schema.SObjectType.Account.getRecordTypeInfosByDeveloperName()
.get('Enterprise').getRecordTypeId();
account.Type = 'Enterprise';
// Set additional enterprise-specific fields
return account;
}
}2. Builder Pattern
The Builder pattern allows for step-by-step construction of complex objects.
Implementation Example
public class OpportunityBuilder {
private Opportunity opportunity;
public OpportunityBuilder() {
this.opportunity = new Opportunity();
}
public OpportunityBuilder withAccount(Id accountId) {
opportunity.AccountId = accountId;
return this;
}
public OpportunityBuilder withAmount(Decimal amount) {
opportunity.Amount = amount;
return this;
}
public OpportunityBuilder withStage(String stage) {
opportunity.StageName = stage;
return this;
}
public OpportunityBuilder withCloseDate(Date closeDate) {
opportunity.CloseDate = closeDate;
return this;
}
public Opportunity build() {
// Validation logic
if (opportunity.AccountId == null) {
throw new IllegalArgumentException('Account is required');
}
return opportunity;
}
}
// Usage
Opportunity opp = new OpportunityBuilder()
.withAccount(accountId)
.withAmount(100000)
.withStage('Prospecting')
.withCloseDate(Date.today().addDays(30))
.build();3. Strategy Pattern
The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable.
Implementation Example
public interface PricingStrategy {
Decimal calculatePrice(Decimal basePrice, Map<String, Object> context);
}
public class StandardPricingStrategy implements PricingStrategy {
public Decimal calculatePrice(Decimal basePrice, Map<String, Object> context) {
return basePrice;
}
}
public class VolumePricingStrategy implements PricingStrategy {
public Decimal calculatePrice(Decimal basePrice, Map<String, Object> context) {
Integer quantity = (Integer) context.get('quantity');
if (quantity > 100) {
return basePrice * 0.9; // 10% discount
}
return basePrice;
}
}
public class PricingContext {
private PricingStrategy strategy;
public PricingContext(PricingStrategy strategy) {
this.strategy = strategy;
}
public void setStrategy(PricingStrategy strategy) {
this.strategy = strategy;
}
public Decimal executePricing(Decimal basePrice, Map<String, Object> context) {
return strategy.calculatePrice(basePrice, context);
}
}4. Repository Pattern
The Repository pattern abstracts data access logic and provides a uniform interface.
Implementation Example
public interface IAccountRepository {
List<Account> findByIds(Set<Id> accountIds);
List<Account> findByType(String type);
Account save(Account account);
void deleteById(Id accountId);
}
public class AccountRepository implements IAccountRepository {
public List<Account> findByIds(Set<Id> accountIds) {
return [
SELECT Id, Name, Type, Industry, AnnualRevenue
FROM Account
WHERE Id IN :accountIds
];
}
public List<Account> findByType(String type) {
return [
SELECT Id, Name, Type, Industry, AnnualRevenue
FROM Account
WHERE Type = :type
];
}
public Account save(Account account) {
upsert account;
return account;
}
public void deleteById(Id accountId) {
delete [SELECT Id FROM Account WHERE Id = :accountId];
}
}5. Service Layer Pattern
The Service layer encapsulates business logic and provides a clean interface.
Implementation Example
public class AccountService {
private IAccountRepository accountRepository;
public AccountService(IAccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
public void processAccountUpgrade(Id accountId) {
Account account = accountRepository.findByIds(new Set<Id>{accountId})[0];
if (account.AnnualRevenue > 1000000) {
account.Type = 'Enterprise';
accountRepository.save(account);
// Trigger related processes
createEnterpriseOpportunities(account);
notifyAccountTeam(account);
}
}
private void createEnterpriseOpportunities(Account account) {
// Business logic for creating opportunities
}
private void notifyAccountTeam(Account account) {
// Business logic for notifications
}
}6. Observer Pattern
The Observer pattern defines a one-to-many dependency between objects.
Implementation Example
public interface IObserver {
void update(String eventType, Map<String, Object> eventData);
}
public class AccountEventManager {
private Map<String, List<IObserver>> observers = new Map<String, List<IObserver>>();
public void subscribe(String eventType, IObserver observer) {
if (!observers.containsKey(eventType)) {
observers.put(eventType, new List<IObserver>());
}
observers.get(eventType).add(observer);
}
public void notify(String eventType, Map<String, Object> eventData) {
if (observers.containsKey(eventType)) {
for (IObserver observer : observers.get(eventType)) {
observer.update(eventType, eventData);
}
}
}
}
public class AccountNotificationObserver implements IObserver {
public void update(String eventType, Map<String, Object> eventData) {
if (eventType == 'ACCOUNT_UPGRADED') {
// Send notification logic
}
}
}7. Command Pattern
The Command pattern encapsulates a request as an object.
Implementation Example
public interface ICommand {
void execute();
void undo();
}
public class CreateOpportunityCommand implements ICommand {
private Opportunity opportunity;
private Id createdOpportunityId;
public CreateOpportunityCommand(Opportunity opportunity) {
this.opportunity = opportunity;
}
public void execute() {
insert opportunity;
createdOpportunityId = opportunity.Id;
}
public void undo() {
if (createdOpportunityId != null) {
delete [SELECT Id FROM Opportunity WHERE Id = :createdOpportunityId];
}
}
}
public class CommandInvoker {
private List<ICommand> commands = new List<ICommand>();
public void addCommand(ICommand command) {
commands.add(command);
}
public void executeAll() {
for (ICommand command : commands) {
command.execute();
}
}
public void undoAll() {
for (Integer i = commands.size() - 1; i >= 0; i--) {
commands[i].undo();
}
}
}8. Dependency Injection
Implement dependency injection for better testability and flexibility.
Implementation Example
public class ServiceContainer {
private static Map<Type, Object> services = new Map<Type, Object>();
public static void register(Type serviceType, Object implementation) {
services.put(serviceType, implementation);
}
public static Object get(Type serviceType) {
return services.get(serviceType);
}
}
// Usage
ServiceContainer.register(IAccountRepository.class, new AccountRepository());
IAccountRepository accountRepo = (IAccountRepository) ServiceContainer.get(IAccountRepository.class);Testing Patterns
Mock Objects
Create mock implementations for testing:
@isTest
public class MockAccountRepository implements IAccountRepository {
private List<Account> accountsToReturn = new List<Account>();
public void setAccountsToReturn(List<Account> accounts) {
this.accountsToReturn = accounts;
}
public List<Account> findByIds(Set<Id> accountIds) {
return accountsToReturn;
}
// Implement other interface methods...
}Best Practices
- Single Responsibility Principle: Each class should have one reason to change
- Open/Closed Principle: Open for extension, closed for modification
- Dependency Inversion: Depend on abstractions, not concretions
- Interface Segregation: Clients shouldn't depend on interfaces they don't use
- Composition over Inheritance: Favor composition for code reuse
Conclusion
These design patterns provide a solid foundation for building maintainable, testable, and scalable Apex applications. Choose patterns that fit your specific use case and always prioritize code clarity and maintainability over cleverness.
Remember: The goal is not to use every pattern, but to use the right patterns for your specific requirements.