Record-Triggered Flows vs Apex Triggers: The Decision Framework
The worst automation mess I've untangled in the last few years wasn't Apex. It was Flow. I walked into an APRA-regulated lender early this year and the Opportunity object had 34 record-triggered flows on it, plus two Apex triggers left over from the previous implementation partner. Nobody in the building could tell me what order they ran in, and to be honest, at that point neither could Salesforce.
That org is the reason I stopped treating "Flow vs Apex" as a religious question and started treating it as an architectural one. Both tools are good. Both are dangerous in the wrong hands. The interesting work is knowing which problem you're actually holding.
The Decision Isn't Flow Versus Apex, It's Where the Complexity Belongs
Most of the "Flow vs Apex" content out there frames it as a skills question — admins use Flow, developers use Apex. That framing is wrong and it leads to bad orgs. I've seen developers write Apex for a single field update because they don't trust the declarative tools, and I've seen admins build 40-node flows that should have been fifteen lines of Apex handled by someone who understood bulk behaviour.
The real question is where the complexity lives. If the logic is a straightforward reaction to a record change — set a field, stamp a date, roll a status forward — that complexity is low and declarative tooling absorbs it cleanly. If the logic branches heavily, calls out to another system, needs precise transaction control, or has to be unit-tested to survive an audit, the complexity is high and it belongs in code.
Nothing about that split is about job titles. It's about matching the tool to the shape of the problem.
Before-Save Flows Are the Cheapest Automation You Can Write
This is the one I'll argue hardest for. If you need to update a field on the same record that triggered the automation, a before-save record-triggered flow should be your default. Not a process, not an after-save flow, not a workflow rule, and not an Apex before-update trigger unless there's a reason.
The reasoning is performance, and it's not marginal. A before-save flow modifies the record in memory before it's committed, so there's no second DML statement and no re-entry into the save order. The old pattern — a workflow rule or process that fires an update after the save — costs you an entire extra save cycle, recursion risk included. Salesforce's own benchmarks put before-save automation at roughly ten times faster than the equivalent process for same-record updates, and I've measured the same shape in the wild. On one Case object I moved four field updates out of Process Builder into a single before-save flow and the save time on bulk loads dropped from around 1.2 seconds a record to under 300 milliseconds.
If you want to understand what actually eats flow performance once you're past that basic win, I've written separately about the five things quietly slowing your flows down. Before-save is step one; it's not the whole story.
Apex Earns Its Place at the Hard Edges
Flow is excellent right up until it isn't, and the edges are predictable. There are a handful of situations where I reach for Apex without hesitating.
Callouts are the obvious one. You cannot make a synchronous HTTP callout from a record-triggered flow the way you can architect it in Apex, and the flow-based async paths are clumsy for anything beyond a fire-and-forget. When an Opportunity closing needs to notify a downstream ledger system, I want that in an Apex handler that enqueues a Queueable, with real error handling and retry logic. Once you're there, choosing the right async mechanism matters more than the flow-vs-apex question ever did.
The second edge is anything that queries in a loop or joins across several objects. Flow's Get Records elements are fine for one or two lookups, but I've seen loops full of Get Records elements blow the SOQL limit at 101 queries in a bulk context, and the flow gives you no bulkification safety net the way a well-written handler does. When the data access gets real, the SOQL patterns that keep you under governor limits are far easier to apply in Apex than to reconstruct node by node in a canvas.
The third is transaction control and testability. Apex gives me savepoints, precise rollback, and — the part auditors care about — proper unit tests with assertions. In a regulated environment, "the flow was tested manually before release" doesn't survive scrutiny. A test class with 90% coverage and real assertions does.
Here's the shape of the Apex I'd write for that Opportunity-to-ledger case, the callout deliberately deferred out of the trigger transaction:
public with sharing class OpportunityTriggerHandler {
public void afterUpdate(List<Opportunity> newList, Map<Id, Opportunity> oldMap) {
List<Id> toSync = new List<Id>();
for (Opportunity opp : newList) {
Opportunity prior = oldMap.get(opp.Id);
if (opp.StageName == 'Closed Won' && prior.StageName != 'Closed Won') {
toSync.add(opp.Id);
}
}
if (!toSync.isEmpty()) {
System.enqueueJob(new LedgerSyncQueueable(toSync));
}
}
}
The non-obvious part is the stage comparison against oldMap. Without it, any later update to a Closed Won record re-fires the callout. That kind of guard is trivial in Apex and genuinely awkward to get right across multiple flows, because you can't easily see prior values without another element and the flows don't share state.
My Actual Decision Framework
When I sit down with a new requirement, I run it through the same short set of questions.
Is it a same-record field update with simple logic? Before-save flow. Every time.
Does it react to a record change by touching other records, sending an email, or creating a task, with logic an admin can read? After-save record-triggered flow, one per object where I can help it.
Does it call an external system, need retry logic, query across several objects, run heavy computation, or require unit-tested rollback behaviour? Apex, through a single trigger and a handler class. I don't let that logic sprawl across a dozen triggers — it goes through the single-trigger handler pattern I've written about at length, for exactly the reasons the flow mess above demonstrates.
Is it a bit of both? That's the common case, and the answer is to split it. Let the flow do the declarative reaction and have it call an invocable Apex method for the hard part. The seam between them should be deliberate and documented, not accidental.
The one rule underneath all of it: pick a lane per object and stay in it. The disaster is never Flow or Apex on its own. It's fourteen flows and three triggers on the same object with no owner and no order.
The Consolidation That Didn't Go Cleanly
Back to the lender. The 34 flows had accreted over roughly three years, mostly built by an admin named Priya who'd been there since the original go-live and knew every one of them by heart. That was the problem — the knowledge lived in her head, and record-triggered flows on the same object had no guaranteed execution order at the time, so the org worked partly because Priya kept the sequence straight manually.
We spent about six weeks consolidating. Same-record updates collapsed into one before-save flow. The genuinely declarative reactions merged into a small number of after-save flows with clear entry conditions. Two clusters of logic that were doing cross-object rollups and a callout moved into an Apex handler with tests. We went from 34 flows and two triggers down to four flows and one trigger.
I'd like to say it was clean. It wasn't. Two of the original flows turned out to encode business rules nobody had documented — edge cases around broker-originated deals that only fired a few times a month — and we didn't discover one of them until it broke in production six weeks after go-live. We patched it, added the missing entry condition, and wrote the assertion we should have had. The consolidation was still the right call. But "reduced 34 flows to four" is the tidy version, and the honest version has a production incident in it and a rule we're still not certain we've fully captured.
When Flow Isn't the Answer
The clean answer is "use before-save flows for updates, Apex for the hard edges, and never mix owners on one object." In practice, there are a few places I hold that line loosely.
If a team has no Apex capability at all and won't for the foreseeable future, a well-structured flow that an admin can maintain beats an Apex handler that only the departed contractor understood. Maintainability by the actual team is a real constraint, and I've chosen the technically-inferior flow more than once because it was the thing that would still be understood in two years.
The reverse trap is real too. Flow is genuinely harder to diff, review, and version-control than Apex. You can't read a flow in a pull request the way you read a class, and merge conflicts on flow metadata are miserable. If your delivery model depends on serious peer review — and in regulated work it should — that friction quietly pushes the boundary back towards code, and I let it.
What I'd do differently on that lender engagement is simple. I'd have insisted on documenting the existing flows before touching anything, not during. Every hour we saved by skipping that up front, we paid back with interest chasing an undocumented broker rule through production logs.
Automation on Salesforce isn't a contest between two tools. It's a set of decisions about where complexity is allowed to live, made once and enforced consistently. Get that right and a new requirement is a small, obvious change in a known place. Get it wrong and you end up where I started this — 34 flows deep, hoping the person who understood the order doesn't take annual leave. If you want a second pair of eyes before your org tips into the second state, that's the kind of review I do.
Liked this?
New essays in your inbox when I publish. No cadence, no spam.
You might also like
Custom Metadata Types: The Swiss Army Knife You're Underusing
Most admins use custom metadata for one lookup table and stop. Here's how I use it to kill hardcoded config, dodge governor limits, and keep rules in source.
Read →Domain Layer Patterns in Apex That Survive Real Projects
The domain layer patterns I actually use in production Apex — how fflib domain classes plug into triggers, where validation belongs, and when it's overkill.
Read →When to Use Platform Events vs Change Data Capture
Platform Events and Change Data Capture look similar but solve different problems. Here's how I decide between them on real Salesforce integration work.
Read →