The quick version: one trigger per object, no logic in the trigger body, and a handler class that the trigger calls. Not because it is tidier, but because two triggers on an object have no guaranteed order between them, so the moment there are two, your execution order is undefined and no amount of testing pins it down.
The reason, which is not style
Salesforce does not document an order for multiple triggers on the same object and same event. Whatever order you observe today is an implementation detail, and it can change.
That is the whole argument. Everything else people say about the pattern (testability, readability, separation of concerns) is true and secondary. The primary reason is that one trigger is the only way to make order a thing you control rather than a thing you inherit.
What the trigger should contain
trigger AccountTrigger on Account (before insert, before update, after insert, after update) {
new AccountTriggerHandler().run();
}
That is the whole file, and it should stay that way. The trigger has no test coverage of its own worth having, cannot be debugged comfortably, and every line in it is a line that cannot be reused or called from a test with constructed data.
What the handler should look like
A handler needs three things: a way to know which context it is in, a way to be switched off, and methods small enough to test individually.
public with sharing class AccountTriggerHandler {
@TestVisible private static Boolean disabled = false;
public void run() {
if (disabled || !TriggerSettings.enabled('Account')) { return; }
if (Trigger.isBefore && Trigger.isUpdate) {
normalisePhoneNumbers(Trigger.new);
}
if (Trigger.isAfter && Trigger.isUpdate) {
syncRatingToContacts(Trigger.new, (Map<Id, Account>) Trigger.oldMap);
}
}
private void normalisePhoneNumbers(List<Account> records) { /* ... */ }
private void syncRatingToContacts(List<Account> records, Map<Id, Account> old) { /* ... */ }
}
Two details worth copying. The methods take their data as parameters rather than reading Trigger.new internally, so a test can call them with a constructed list and no DML. And there is a switch — a static for tests, and a custom setting or metadata check for production, because the day you need to load data without firing automation always arrives.
Recursion control, and the version that actually works
A static Boolean is the usual guard, and it is right for “run this once per transaction”:
private static Boolean hasRun = false;
if (hasRun) { return; }
hasRun = true;
It is wrong for bulk work, because a data load processes many batches in one transaction and the second batch is skipped silently. That failure is quiet, partial and extremely hard to reproduce from a bug report.
The safer guard tracks record Ids rather than a flag:
private static Set<Id> processed = new Set<Id>();
List<Account> toProcess = new List<Account>();
for (Account a : Trigger.new) {
if (!processed.contains(a.Id)) {
toProcess.add(a);
processed.add(a.Id);
}
}
Same protection against a loop, no silent skipping of a later batch. If you only ever remember one thing from this article, make it this one: a static Boolean is a per-transaction switch, not a per-record one.
Where the pattern goes wrong in practice
The handler becomes the monolith. One class, two thousand lines, every requirement since 2019. Split by concern into services the handler calls. The handler’s job is routing, not logic.
Queries inside loops move from the trigger into the handler, where they are equally fatal and slightly harder to see. The 101-query limit does not care which class the query is in.
A second trigger appears anyway, usually from a managed package or a colleague in a hurry. Check for it before you debug an ordering problem, and remember the same object can also carry flows, which have their own place in the order of execution.
What I would do on an existing org
Do not rewrite working triggers into the pattern for its own sake. Consolidate when there is a second trigger on the same object, when you hit an ordering bug, or when you are about to add substantial logic. Rewrites of code that works, with no test suite proving equivalence, are how a quiet Tuesday becomes an incident.
Frequently asked
Do I need a framework for this? No. A trigger, a handler and a switch is enough for most orgs. Frameworks add a base class, dispatch tables and conventions — helpful at scale, and overhead when you have four triggers.
Should the handler be with sharing or without? with sharing unless you have a written reason, and the reason belongs in a comment. Triggers run in system context by default, which surprises people who assume the running user’s access applies.
Where do flows fit? Beside it, at a defined point in the save order. Decide per object whether logic lives in Flow or Apex, and write the decision down. The mechanics are in Flow or Apex trigger.
Is one trigger per object still right with only one requirement? Yes, because it costs nothing today and prevents the second trigger tomorrow.
