Every time I review a solution design with a customer, the same debate surfaces within the first ten minutes: “Should we build this in Flow or Apex?” I have been on both sides of bad decisions here. I have seen flows so complex they could only be debugged with a whiteboard and a prayer. I have also seen Apex triggers written to do things a three-node flow could handle in fifteen minutes.
The real answer is not “always use Flow” or “always use Apex.” The answer is a framework for making the right call based on the specific requirements in front of you. Let me share the framework I use.
The Problem
A developer builds a complex Opportunity automation in Apex — 300 lines of logic handling field updates, task creation, and notifications. Six months later the business wants to change the follow-up task subject. The admin cannot touch it, a dev ticket is filed, it sits in the backlog for three weeks, and the business is frustrated that such a simple change requires a deployment.
The Solution
Use a hybrid architecture: a Record-Triggered Flow handles the orchestration and simple field updates that admins may need to tweak, while an Apex Action (called from the Flow) handles any complexity that requires code. The admin owns the Flow canvas; the developer owns the Apex Action. Both can evolve independently.
The Animated Decision Tree
The Core Principle: Maintainability First
Before I talk about technical criteria, I want to establish the most important principle: the best automation tool is the one that the people maintaining your org can actually support.
A perfectly architected Apex trigger that only two developers in the company understand is worse than a slightly less optimal Flow that your admin team can debug and update independently. When consulting on org architecture, always ask: “What does your team look like two years from now?”
Salesforce invested heavily in Flow because it genuinely democratizes automation. When I consult on org architecture, I ask: “What does your team look like two years from now?” If there is a good chance it is admin-heavy, Flow wins.
When Flow Is the Right Answer
Simple to Medium Field Updates and Validations
Record-Triggered Flows handle field updates, related record operations, and notification emails extremely well. The point-and-click interface means changes can be made during a Salesforce release without a development cycle.
Real scenario: An opportunity moves to “Closed Won.” The Flow should:
- Set
Opportunity.Close_Won_Date__cto today - Create a follow-up Task for the owner
- Update the parent Account’s
Last_Won_Opportunity_Date__c
All of this is straightforward in a Flow. No Apex needed. An admin can look at the flow, understand it, and change the task subject without filing a dev ticket.
When Admins Need to Make Regular Changes
If your automation involves thresholds, messages, or logic that business stakeholders tune regularly (“escalate after 3 days” might become “escalate after 2 days” next quarter), put that logic in a Flow where an admin can update it. Putting it in Apex means a deployment every time the business tweaks a rule.
Screen Flows for Guided Processes
Anything that requires user input mid-process belongs in a Screen Flow. Apex cannot render a UI. If the requirement is “ask the user a question and branch based on the answer,” that is a Flow.
When Apex Is the Right Answer
External Callouts
Apex with @future(callout=true) or Queueable with Database.AllowsCallouts is the only way to call external HTTP APIs from automation. Flows can call Apex Actions, but if you need an HTTP callout, Apex is unavoidable.
public class ExternalOrderService implements Queueable, Database.AllowsCallouts {
private List<Id> orderIds;
public ExternalOrderService(List<Id> ids) { this.orderIds = ids; }
public void execute(QueueableContext ctx) {
List<Order__c> orders = [SELECT Id, Name, Total__c FROM Order__c WHERE Id IN :orderIds];
for (Order__c o : orders) {
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:ERP_System/orders');
req.setMethod('POST');
req.setBody(JSON.serialize(new Map<String, Object>{
'salesforceId' => o.Id,
'orderName' => o.Name,
'total' => o.Total__c
}));
new Http().send(req);
}
}
}Complex Boolean Logic
Flows handle conditions well for simple branching. But when your logic has more than three levels of nesting, conditional groups with AND/OR across many fields, or dynamic conditions built at runtime, Apex is cleaner and safer. Visual complexity in a flow correlates directly with the likelihood of a bug.
// This conditional logic is unreadable as a flow — use Apex
public static Boolean requiresEscalation(Case c, Account acct, List<Case> history) {
Boolean isPremiumCustomer = acct.Support_Tier__c == 'Platinum' || acct.Annual_Revenue__c > 1000000;
Boolean isStalled = c.Status == 'In Progress' && c.Last_Modified_By_Customer__c < Date.today().addDays(-2);
Boolean hasHistory = history.size() > 3 && history[0].CreatedDate > Date.today().addDays(-30);
Boolean isCriticalProduct = c.Product_Line__c == 'Core Platform' && c.Impact__c == 'High';
return isPremiumCustomer && (isStalled || (hasHistory && isCriticalProduct));
}Unit-Tested Business Logic
Apex has a full testing framework with @isTest, test factories, mocks, and code coverage requirements. Flow testing in Salesforce has improved, but Apex is still superior for scenarios requiring precise assertions, mock callouts, and complex data setup. Any logic that must be verified automatically in a CI/CD pipeline should live in Apex.
Batch Processing
Salesforce Batch Apex is the only way to process more than a few thousand records reliably. There is no Flow equivalent for iterating over millions of records in a governor-safe way.
The Hybrid Pattern: Flow Orchestrates, Apex Executes
My favorite architecture pattern is the hybrid: a Record-Triggered Flow handles the orchestration and simple field updates, and calls an Apex Action only when it encounters complexity that declarative tools cannot handle.
@InvocableMethod(label='Process Order Fulfillment'
description='Sends order to ERP and updates fulfillment status')
public static List<Result> processOrders(List<Request> requests) {
List<Result> results = new List<Result>();
List<Id> orderIds = new List<Id>();
for (Request r : requests) {
orderIds.add(r.orderId);
}
// Enqueue the callout work — cannot do callouts synchronously from Flow invocable
System.enqueueJob(new ExternalOrderService(orderIds));
for (Request r : requests) {
Result res = new Result();
res.success = true;
res.message = 'Order queued for ERP sync';
results.add(res);
}
return results;
}
public class Request {
@InvocableVariable(required=true)
public Id orderId;
}
public class Result {
@InvocableVariable
public Boolean success;
@InvocableVariable
public String message;
}The Flow triggers on Order update, handles setting simple status fields declaratively, then calls this Apex Action for the ERP sync. The admin can see and understand the flow. The developer owns the Apex. Neither steps on the other.
Governor Limit Implications
Flow Limits
Flows share the same per-transaction governor limits as Apex. A Flow that has a “Get Records” element inside a loop is exactly as bad as SOQL in an Apex loop — it burns the same SOQL budget from the same 100-query allowance.
Since Spring ‘22, Salesforce enforces Flow DML bulkification automatically in most cases: if your flow updates 200 records, the DML happens in one bulk statement. But if you have a loop with an explicit DML action inside it, you are back to one DML per iteration.
When Both Fire
If you have both a Record-Triggered Flow and an Apex trigger on the same object, they share the same governor limits. A flow that uses 40 SOQL queries and a trigger that uses 40 SOQL queries together consume 80 of the 100-query budget. Review all automations on an object together, not in isolation.
Use Salesforce’s Flow Trigger Explorer (Setup > Flows > Flow Trigger Explorer) to see all Record-Triggered Flows on a given object in one view, alongside their order of execution. This is the fastest way to audit automation overlap and combined governor limit usage before deploying new automations.
My Decision Checklist
Full Decision Checklist (7 Questions)
I run through these questions in order when designing automation:
- Will an admin need to modify this without a deployment? YES → lean toward Flow.
- Does it require an external HTTP callout? YES → must involve Apex.
- Does it require complex conditional logic (3+ levels of nesting)? YES → Apex.
- Does it require unit tests in CI/CD? YES → Apex.
- Does it involve more than ~10,000 records? YES → Batch Apex.
- Is it a guided user interface (screen flow)? YES → Screen Flow.
- Is everything above answered NO? → Flow, probably.
One More Thing: The Overlap Zone
There is a category where either tool works, and your org context decides: moderate-complexity field updates with related record operations. Both a Flow and a trigger can do this. In this overlap zone, I default to Flow if the team has admins who will maintain the org, and default to Apex if the team is developer-heavy and uses version control and CI/CD.
An org with 40 flows and 5 triggers is more maintainable than one with a random mix of 20 flows and 25 triggers covering similar scenarios in different ways. Pick a default and stick with it.
What is the most complex Flow you have ever built before deciding it should have been Apex instead? Or the reverse — Apex that you eventually replaced with a clean Flow? I always find the war stories more instructive than the theory.
Knowledge Check
How did this article make you feel?
Comments
Salesforce Tip