Log Entry
Trigger Architecture Without a 40-Class Framework
An Apex trigger can become unmaintainable in two opposite ways.
The first is obvious:
trigger AccountTrigger on Account (before insert, before update, after update) {
// 900 lines of everything.
}
The second is more respectable-looking:
- trigger
- dispatcher
- base interface
- abstract handler
- handler factory
- metadata registry
- action loader
- unit-of-work wrapper
- twelve one-method classes
The first has no structure.
The second can have more structure than problem.
Most orgs need something in the middle.
Quick snapshot
My default trigger architecture is:
- one trigger per object
- no business logic in the trigger body
- one small handler or router for trigger contexts
- cohesive services for domain operations
- collection queries and DML
- change-aware entry criteria
- recursion protection scoped to the actual operation
- direct tests of services plus a few trigger integration tests
Salesforce's record-triggered automation guide recommends a governed primary entry point per object and choosing Flow, hybrid automation, or Apex according to automation density.
That principle matters more than adopting a fashionable trigger framework.
Keep the trigger boring
trigger OpportunityTrigger on Opportunity (
before insert,
before update,
after insert,
after update
) {
OpportunityTriggerHandler.run(
Trigger.operationType,
Trigger.new,
Trigger.oldMap
);
}
The trigger declares when it runs and forwards context.
It does not query, perform DML, send emails, enqueue jobs, or calculate pricing.
That gives the object one visible Apex entry point without making the trigger itself the application.
Route explicitly
public class OpportunityTriggerHandler {
public static void run(
System.TriggerOperation operation,
List<Opportunity> newRecords,
Map<Id, Opportunity> oldRecordById
) {
switch on operation {
when BEFORE_INSERT {
OpportunityDefaults.apply(newRecords);
}
when BEFORE_UPDATE {
OpportunityValidation.validateChanges(
newRecords,
oldRecordById
);
}
when AFTER_INSERT {
OpportunityRollupService.refreshAccounts(newRecords);
}
when AFTER_UPDATE {
List<Opportunity> stageChanges = changedStageRecords(
newRecords,
oldRecordById
);
OpportunityRollupService.refreshAccounts(stageChanges);
}
}
}
}
This is not sophisticated.
That is one of its strengths. A developer can open it and see which service owns each context.
Add abstraction when repeated needs appear, not before the first trigger exists.
Use before context for same-record changes
If the requirement is to set a field on the records being inserted or updated, do it in a before trigger where possible.
public class OpportunityDefaults {
public static void apply(List<Opportunity> opportunities) {
for (Opportunity opportunity : opportunities) {
if (opportunity.Review_Status__c == null) {
opportunity.Review_Status__c = 'Pending';
}
}
}
}
No extra DML is required.
Using an after trigger to update the same records creates extra work and invites recursion.
Filter for meaningful changes
An update trigger runs because a record changed, not necessarily because the field your service cares about changed.
private static List<Opportunity> changedStageRecords(
List<Opportunity> newRecords,
Map<Id, Opportunity> oldRecordById
) {
List<Opportunity> changedRecords = new List<Opportunity>();
for (Opportunity opportunity : newRecords) {
Opportunity oldOpportunity = oldRecordById.get(opportunity.Id);
if (opportunity.StageName != oldOpportunity.StageName) {
changedRecords.add(opportunity);
}
}
return changedRecords;
}
Pass only meaningful changes into expensive work.
This is better than letting every service rerun on every update and trying to suppress the consequences later.
Bulkify the service, not only the trigger
A loop-free trigger can still call a service that queries one record at a time.
Every downstream method should accept and process collections where the trigger supplies collections.
public class OpportunityRollupService {
public static void refreshAccounts(List<Opportunity> opportunities) {
Set<Id> accountIds = new Set<Id>();
for (Opportunity opportunity : opportunities) {
if (opportunity.AccountId != null) {
accountIds.add(opportunity.AccountId);
}
}
if (accountIds.isEmpty()) {
return;
}
// Query aggregate data once and update Accounts as one collection.
}
}
Bulkification is an end-to-end property of the call path.
It is not achieved by moving a query from the trigger into a class with "Service" in its name.
Avoid the global static Boolean
This common guard is too broad:
if (TriggerGuard.hasRun) {
return;
}
TriggerGuard.hasRun = true;
It can skip legitimate work when a transaction processes more records in another trigger invocation. It also hides why recursion exists.
Prefer, in order:
- make updates change-aware and idempotent
- avoid unnecessary same-record DML
- track the specific record IDs and operation already processed
- use a narrowly scoped guard around the actual recursive path
A set of processed IDs is often safer than one Boolean, but even that needs careful semantics. A record might legitimately require a second pass after a different meaningful transition.
The best recursion defence is code that does nothing when the relevant state is already correct.
Keep orchestration separate from domain logic
The handler understands trigger contexts.
The service should understand the business operation.
Bad service API:
OpportunityService.handleAfterUpdate(Trigger.new, Trigger.oldMap);
Better service API:
RenewalService.createRenewalsFor(closedWonOpportunities);
The second method can be reused from a repair job or tested without pretending a trigger is running.
Let the handler translate platform context into a business request.
Coordinate Flow and Apex deliberately
One trigger per object does not solve an object that also has several record-triggered Flows doing overlapping work.
Decide which mechanism is the primary entry strategy for the object:
- low-density, visible automation may belong in Flow
- medium complexity may use Flow with focused invocable Apex
- high-density, performance-sensitive automation may belong in an Apex trigger
Mixed automation is sometimes unavoidable, especially in a mature org. Document ordering, ownership, and cross-object effects. Do not allow Flow and Apex to become two teams independently reacting to the same record.
Test at two levels
Service tests should cover most business cases directly:
- collection behaviour
- normal cases
- edge cases
- permissions where relevant
- errors and invariants
Then keep a smaller set of trigger integration tests proving:
- the correct context routes to the service behaviour
- irrelevant field changes do not cause work
- bulk DML succeeds
- the full automation path stays within limits
Do not make every domain test insert a record solely to reach a private algorithm through the trigger.
When a framework earns its place
A larger trigger framework can be justified when the org genuinely needs:
- metadata-controlled action ordering
- package extension points
- consistent bypass controls for data operations
- many teams contributing independent actions
- central instrumentation across a large automation estate
Those are real requirements.
They should be named and tested.
"All serious Salesforce projects use a framework" is not a requirement.
The architecture test
A new developer should be able to answer:
- What runs when this object changes?
- In what order?
- Which class owns this business rule?
- Does it work for 200 records?
- What prevents repeated work?
- How is it tested?
If a 40-class framework makes those answers clearer, use it.
If four straightforward classes make them clearer, use those.
The goal is understandable automation, not maximum architecture per trigger.