Apex Development12 min read

Building Scalable Apex Architecture: Design Patterns for Lightning Platform

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

  1. Single Responsibility Principle: Each class should have one reason to change
  2. Open/Closed Principle: Open for extension, closed for modification
  3. Dependency Inversion: Depend on abstractions, not concretions
  4. Interface Segregation: Clients shouldn't depend on interfaces they don't use
  5. 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.