Why Apex Needs Deliberate Architecture
A naive Salesforce org often ends up with business logic scattered across multiple triggers on the same object, several Process Builder flows, and duplicated validation in both an Apex controller and a Flow — leading to unpredictable execution order and 'why did this run twice' bugs. Design patterns borrowed from enterprise software (and adapted for Apex's trigger-per-object, governor-limited execution model) solve this by giving every concern a single, predictable home: one trigger per object that delegates to a handler class, one service class per business capability, and one selector class per object for queries. This isn't academic tidiness — it directly determines whether a codebase can be safely extended by a second developer without breaking existing behavior.
Cricket analogy: Scattered logic across multiple triggers is like five different assistant coaches independently telling a batsman to change his stance during the same innings — without one head coach coordinating, the player gets contradictory, overlapping instructions.
The Trigger Handler Pattern
The trigger handler pattern reduces every trigger to a thin, near-empty entry point — often just one line instantiating a handler class and calling a method like new OpportunityTriggerHandler().run() — with all actual logic living in a class that exposes methods per context (beforeInsert, afterUpdate, etc.). This achieves three things: it guarantees only one trigger fires per object (avoiding Salesforce's undefined ordering when multiple triggers exist on the same object), it makes the logic unit-testable without needing to fire a real DML operation through the trigger context, and it lets you toggle logic on/off (via a custom setting or bypass flag) during data migrations without deleting code.
Cricket analogy: The thin trigger delegating to a handler is like an on-field captain who doesn't personally decide every tactical detail but relays the coach's game plan — one clear chain of command instead of the trigger itself trying to do everything.
// Trigger: thin, delegates everything
trigger OpportunityTrigger on Opportunity (before insert, before update, after update) {
new OpportunityTriggerHandler().run();
}
// Handler: one method per context, easily unit tested directly
public with sharing class OpportunityTriggerHandler {
public void run() {
if (Trigger.isBefore && Trigger.isInsert) {
beforeInsert(Trigger.new);
}
if (Trigger.isAfter && Trigger.isUpdate) {
afterUpdate(Trigger.newMap, Trigger.oldMap);
}
}
public void beforeInsert(List<Opportunity> newOpps) {
OpportunityService.setDefaultProbability(newOpps);
}
public void afterUpdate(Map<Id, Opportunity> newMap, Map<Id, Opportunity> oldMap) {
List<Opportunity> closedWon = new List<Opportunity>();
for (Opportunity opp : newMap.values()) {
Opportunity old = oldMap.get(opp.Id);
if (opp.StageName == 'Closed Won' && old.StageName != 'Closed Won') {
closedWon.add(opp);
}
}
if (!closedWon.isEmpty()) {
OpportunityService.notifyFinanceTeam(closedWon);
}
}
}Service Layer and Selector Pattern
The service layer holds the actual business logic as static methods operating on collections of sObjects — OpportunityService.applyClosingDiscount(opps) — decoupled from where it's called from, so the same logic can be invoked identically from a trigger handler, a Queueable job, a Batch Apex execute() method, or an @AuraEnabled controller without duplication. The selector pattern complements this by centralizing all SOQL for a given object into one class (OpportunitySelector) with methods like selectById(Set<Id> ids) or selectOpenByAccountId(Id accId) — this makes it trivial to add a WITH SECURITY_ENFORCED clause or a new field to every query for that object in one place, rather than hunting through a dozen scattered SOQL statements.
Cricket analogy: A service layer callable from anywhere is like a well-drilled batting technique that works identically whether the batsman is facing pace in a Test match or spin in a T20 — the fundamental skill (service method) doesn't change based on the format it's called from.
A useful selector class convention is to expose a base query method (e.g., selectById) that other, more specific methods reuse and extend, and to always include WITH SECURITY_ENFORCED or a stripInaccessible() call there so every consumer automatically benefits from the security check without having to remember it themselves.
Unit of Work Pattern for Cross-Object DML
When a single operation needs to insert or update related records across multiple sObject types — say, creating an Opportunity, its OpportunityLineItems, and a related Task in one logical action — the Unit of Work pattern (popularized by Andrew Fawcett's fflib library, though teams often build a lightweight version themselves) registers all the pending changes first and commits them together at the end via a single ordered set of DML calls. This solves a subtle bulkification trap: naively inserting a parent record, then immediately looping to insert children referencing its new Id, works fine for one record but becomes an insert-inside-a-loop anti-pattern the moment you're processing 200 parents at once; a Unit of Work batches the parent inserts, resolves all the new Ids, then batches the child inserts in one shot.
Cricket analogy: The Unit of Work batching related inserts is like a scorer who logs an entire over's worth of deliveries, runs, and extras together in one consolidated entry rather than updating the scoreboard after every single ball — fewer, larger, well-ordered updates.
Whichever pattern you adopt, avoid re-implementing it inconsistently across an org. A codebase with three different trigger handler conventions (one per developer who's touched it) is often worse than no pattern at all, because new developers can't predict which convention a given trigger follows. Document the chosen pattern in a README or architecture doc and enforce it in code review.
- Use exactly one trigger per object that delegates immediately to a handler class — never put logic directly in the trigger body.
- Trigger handlers expose one method per context (beforeInsert, afterUpdate, etc.) and stay free of business logic themselves.
- Service layer classes hold reusable business logic as static methods callable identically from triggers, batch jobs, and Aura controllers.
- Selector classes centralize all SOQL for an object in one place, making security enforcement and field additions consistent.
- The Unit of Work pattern batches cross-object DML into one ordered commit, avoiding insert/update-in-a-loop anti-patterns.
- Consistency across the org matters more than which specific pattern you choose — mixed conventions confuse maintainers.
- Design patterns exist to make bulkification and testability the default outcome, not something bolted on afterward.
Practice what you learned
1. What is the main benefit of the trigger handler pattern?
2. What problem does the selector pattern primarily solve?
3. What bulkification trap does the Unit of Work pattern specifically address?
4. Why should service layer logic be implemented as reusable static methods rather than embedded directly in a trigger handler?
5. What is a key risk of an org having multiple different trigger handler conventions introduced by different developers over time?
Was this page helpful?
You May Also Like
Governor Limits
Understand why Salesforce enforces per-transaction governor limits and how to write bulk-safe Apex that never hits SOQL, DML, CPU, or heap ceilings.
Testing Apex Code
Learn how to write robust Apex test classes, use assertions and mocks, and produce meaningful code coverage that survives real deployments.
Security and Sharing in Apex
Learn how CRUD/FLS enforcement, sharing keywords, and the WITH SECURITY_ENFORCED / stripInaccessible tools keep Apex code from leaking data users shouldn't see.
Related Reading
Related Study Notes in Programming
Browse all study notesApache Spark Study Notes
Programming · 30 topics
ProgrammingApache Flink Study Notes
Programming · 30 topics
ProgrammingHadoop Study Notes
Programming · 30 topics
ProgrammingSnowflake Study Notes
Programming · 30 topics
ProgrammingApache Airflow Study Notes
Programming · 30 topics
Programmingdbt (Data Build Tool) Study Notes
Programming · 30 topics