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();
}
🔹 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() {}
}
🔹 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();
}
}
🔹 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.');
}
}
}
✅ 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);
}
}
}
🔹 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];
}
}
🔹 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());
}
}
}
🔹 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();
}
}
🧠 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)