DEV Community

Rohit Maharashi
Rohit Maharashi

Posted on

Enterprise-Grade Apex Trigger Architecture for Scalable Salesforce Orgs

As Salesforce orgs evolve into mission-critical platforms, Apex trigger management often turns into a nightmare. Even with popular frameworks, teams eventually hit bottlenecks and complexity spirals out of control.

In this article, I’ll walk you through a modular, maintainable, and enterprise-grade Apex trigger architecture — built to scale gracefully and keep your sanity intact.


💥 Common Problems in Real-World Apex Trigger Implementations

Despite best intentions, real-world triggers tend to suffer from:

  • 🔁 Multiple unnecessary iterations over Trigger.new
  • 🧱 Overloaded trigger handlers that try to do too much
  • 🧵 Poor state sharing between services and layers
  • ❌ Redundant and unoptimized SOQL queries
  • 🧯 No strategy for partial failures or error aggregation
  • 🔄 Scattered DMLs that violate governor limits
  • 🔍 Difficult-to-test and debug logic

We need to go beyond the usual handler frameworks and adopt responsibility-driven modular design.


🎯 Design Goals and Principles

An ideal Apex trigger architecture must be:

  • Single Responsibility at every layer
  • Bulk-safe across all operations
  • No redundant loops on Trigger.new
  • Centralized DML and error control
  • Modular, reusable, and testable
  • Extensible for future business logic

🧩 Proposed Architecture Overview

Here’s the layered flow:

Trigger → Handler → Services → Selectors → UnitOfWork → ContextManager

Layer Responsibility
Trigger Lightweight. Only calls the handler.
Handler Detects context (beforeInsert, etc.) and delegates work
Services Business logic split into Simple (per-record) & Bulk (multi-record)
Selectors Encapsulate and reuse SOQL queries
UnitOfWork Batched DML operations and error-safe commits
ContextManager Shared state and error collection

🧪 Example: Account Trigger Flow

🔹 1. Minimal Trigger

trigger AccountTrigger on Account (
    before insert, after insert,
    before update, after update
) {
    new AccountTriggerHandler().run();
}
Enter fullscreen mode Exit fullscreen mode

🔹 2. Generic Trigger Handler

public virtual class TriggerHandler {
    public void run() {
        if (Trigger.isBefore) {
            if (Trigger.isInsert) beforeInsert();
            if (Trigger.isUpdate) beforeUpdate();
            if (Trigger.isDelete) beforeDelete();
        }
        if (Trigger.isAfter) {
            if (Trigger.isInsert) afterInsert();
            if (Trigger.isUpdate) afterUpdate();
            if (Trigger.isDelete) afterDelete();
        }
    }

    protected virtual void beforeInsert() {}
    protected virtual void beforeUpdate() {}
    protected virtual void beforeDelete() {}
    protected virtual void afterInsert() {}
    protected virtual void afterUpdate() {}
    protected virtual void afterDelete() {}
}
Enter fullscreen mode Exit fullscreen mode

🔹 3. Account-Specific Handler

public with sharing class AccountTriggerHandler extends TriggerHandler {
    private final AdvancedUnitOfWork uow = new AdvancedUnitOfWork();

    protected override void beforeInsert() {
        AccountTriggerContext ctx = new AccountTriggerContext((List<Account>) Trigger.new, (Map<Id, Account>) Trigger.oldMap);
        for (Account acct : ctx.newRecords) {
            new AccountValidationService().validate(acct);
        }
    }

    protected override void afterInsert() {
        AccountTriggerContext ctx = new AccountTriggerContext((List<Account>) Trigger.new, (Map<Id, Account>) Trigger.oldMap);
        new AccountPostInsertService().handleAfterInsert(ctx.newRecords, uow);
        uow.commit();
        ctx.clear();
    }
}
Enter fullscreen mode Exit fullscreen mode

🔹 4. 🧠 Domain Services

✅ Validation Service

public with sharing class AccountValidationService {
    public void validate(Account acct) {
        if (String.isBlank(acct.Name)) {
            acct.addError('Account name is mandatory.');
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

✅ Post-Insert Service

public with sharing class AccountPostInsertService {
    public void handleAfterInsert(List<Account> accounts, AdvancedUnitOfWork uow) {
        if (accounts.isEmpty()) return;

        List<Contact> relatedContacts = ContactSelector.selectByAccountIds(accounts);
        for (Contact con : relatedContacts) {
            con.Description = 'Updated after insert';
            uow.registerUpdate(con);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

🔹 5. 🔍 Selector Layer (SOQL Isolation)

public with sharing class ContactSelector {
    public static List<Contact> selectByAccountIds(List<Account> accounts) {
        Set<Id> accountIds = new Set<Id>();
        for (Account acc : accounts) {
            if (acc.Id != null) accountIds.add(acc.Id);
        }

        return accountIds.isEmpty() ? new List<Contact>() :
            [SELECT Id, FirstName, LastName, AccountId FROM Contact WHERE AccountId IN :accountIds];
    }
}
Enter fullscreen mode Exit fullscreen mode

🔹 6. 🧱 Advanced Unit of Work

Handles all DMLs centrally. Avoids mixed DML and supports partial error capture.

public with sharing class AdvancedUnitOfWork {
    private List<SObject> inserts = new List<SObject>();
    private List<SObject> updates = new List<SObject>();

    public void registerInsert(SObject record) {
        if (record != null) inserts.add(record);
    }

    public void registerUpdate(SObject record) {
        if (record != null) updates.add(record);
    }

    public void commit() {
        if (!inserts.isEmpty()) handleDml('insert', inserts);
        if (!updates.isEmpty()) handleDml('update', updates);
    }

    private void handleDml(String operation, List<SObject> records) {
        try {
            Database.SaveResult[] results;

            if (operation == 'insert') results = Database.insert(records, false);
            else results = Database.update(records, false);

            for (Database.SaveResult res : results) {
                if (!res.isSuccess()) {
                    TriggerContextManager.addError(res.getErrors()[0].getMessage());
                }
            }
        } catch (Exception e) {
            TriggerContextManager.addError(e.getMessage());
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

🔹 7. 📦 Trigger Context Manager

Shared memory and error collector across services.

public class TriggerContextManager {
    public static Map<String, Object> sharedData = new Map<String, Object>();
    public static List<String> errors = new List<String>();

    public static void clear() {
        sharedData.clear();
        errors.clear();
    }

    public static void addError(String msg) {
        errors.add(msg);
    }

    public static Boolean hasErrors() {
        return !errors.isEmpty();
    }
}
Enter fullscreen mode Exit fullscreen mode

🧠 Why This Architecture Works

  • 💡 One job per class = better readability and unit testing
  • 💡 Selectors prevent query duplication
  • 💡 Services remain simple and scalable
  • 💡 UnitOfWork avoids mixed DML chaos
  • 💡 ContextManager gives shared state and captures errors
  • 💡 Extremely testable: services and selectors are easy to mock/test

✅ Final Thoughts

With this approach, your Salesforce trigger logic becomes:

  • 🚀 Robust for enterprise-scale applications
  • 🔍 Clear and maintainable
  • 🧪 Testable by default
  • 🔄 Flexible for future changes
  • ⚙️ Aligned with governor limits and best practices

Let your triggers do less. Let your architecture do the heavy lifting.

🙌 Thanks for reading!
If this helped you, consider sharing it with your team, leaving a ❤️, or dropping a comment!

Top comments (0)