The rule of thumb
Start in Flow. Drop to Apex when you need one of: complex loops on large collections, callouts with retry logic, dynamic SOQL, custom exceptions, or shared logic across multiple entry points.
Decision matrix
| Need | Flow | Apex |
|---|---|---|
| Simple record update | ✅ | ❌ overkill |
| Loop over 200+ records with calculation | ⚠️ hits limits | ✅ |
| REST callout, no retry | ✅ (HTTP Callout) | ✅ |
| REST callout, exponential backoff | ❌ | ✅ |
| Dynamic SOQL (fields chosen at runtime) | ❌ | ✅ |
| Reusable across triggers, LWC, REST | ⚠️ | ✅ |
| Business rules a BA should own | ✅ | ❌ |
| Formula field limits exceeded | ✅ (Formula elements) | ✅ |
Flow patterns that work
Record-Triggered Flow — one per object, with prioritized entry criteria:
Priority 10: Set defaults on Insert
Priority 20: Route to queue on Insert (High Priority cases)
Priority 30: Escalation timer on Update (Status → Escalated)
Use {!$Record.IsClone} and {!$Record__Prior.OwnerId} to skip re-runs correctly.
Sub-flows — extract shared logic once, invoke everywhere:
Sub-flow: Calculate_Health_Score
Inputs: AccountId
Outputs: HealthScore, TopReason
Invoke from Flow, from Apex (Flow.Interview), from LWC (lightning/flowSupport).
Apex patterns Flow can't replace
Trigger framework:
public abstract class TriggerHandler {
public void run() {
switch on Trigger.operationType {
when BEFORE_INSERT { beforeInsert(); }
when AFTER_UPDATE { afterUpdate(); }
// ...
}
}
protected virtual void beforeInsert() {}
protected virtual void afterUpdate() {}
}
Callout with retry:
public class ResilientHttp {
public static HttpResponse send(HttpRequest req, Integer max) {
Integer attempt = 0;
while (true) {
HttpResponse resp = new Http().send(req);
if (resp.getStatusCode() < 500 || ++attempt >= max) return resp;
Long backoff = (Long)Math.pow(2, attempt) * 200;
Datetime until = Datetime.now().addMilliseconds(backoff.intValue());
while (Datetime.now() < until) { /* CPU-safe wait */ }
}
}
}
(For real backoff, delegate to Queueable + System.enqueueJob with a scheduled retry.)
Migrating Process Builder
Every Process Builder should be gone. Migration script:
- Export PB metadata (SFDX
force:source:retrieve -m ProcessBuilder) - Recreate as Record-Triggered Flow with matching entry criteria
- Migrate one PB per PR, deploy, verify, then delete the PB
- Run Optimizer report weekly to catch newly created PBs
Testing
- Flows: Flow Test framework (native, JSON test cases, CI-runnable)
- Apex: ApexTest + assertion libraries, aim 85%+ meaningful coverage
- End-to-end: Provar / UTAM for UI, drives both
Anti-patterns
- One Flow per record change (fragmentation) — consolidate per object
- Apex for record updates that a Flow does in one node
- Formula fields hiding as Flow — if it's a value derived from same-record fields, use a Formula
- "Just this once" hardcoded IDs — always Custom Metadata
