GTM Engineering
Retiring Apex With Flows: A Migration Runbook That Won't Break Prod
Deleting the old trigger is how you break prod. The safe path is a five-stage ladder: inventory, shadow, cutover, stub, retire, and it promotes only the cluster you tested.
· 13 min read
For a decade the answer to every new requirement in Salesforce was another Apex trigger. Need a field stamped on save? Write a trigger. Need a downstream record touched? Add to the trigger. The org accreted logic one class at a time until nobody could say what fired in what order, and the day you try to replace one of those triggers with a Flow, the tempting move is to delete it. Do not. Deleting a covered trigger is how you discover it counted toward org-wide coverage, that a test elsewhere leaned on it, and that your deploy now fails below the 75% floor mid-release, in production, at the worst possible moment (Salesforce deployment requirement).
I moved a pile of legacy triggers to before-save Flows while collapsing an org from 282 automations to 82. The Flow is the easy part. The part that breaks prod is the handoff: the window where both the old Apex and the new Flow are live, or neither is, or Salesforce reorders the survivors and something reads a field before it is written. So the migration is not a rewrite, it is a five-stage climb where each stage keeps a reverse gear. Scroll it first, then I will show you the code and the worked math under every rung.
- L5Retire the shella sprint later
Delete the stubbed, deactivated trigger in a separate boring release a sprint later, once the Flow has run clean in prod for a full quarter. Deletion is never part of the cutover.
- L4Stub the body75% floor holds
Gut the trigger logic but keep a minimal body and its test so org-wide coverage never drops below the 75% deploy floor. The trigger is now an inert shell that documents what used to run here.
- L3Cut over via metadata1 deploy back
Set the trigger status to Inactive in its meta.xml and deploy, Flow goes Active in the same package. Never delete. The class still exists, counts for coverage, and reactivates in one deploy.
- L2Shadow the Flowdiff, not trust
Build the before-save Flow and run it alongside the still-live trigger in a sandbox. Diff outputs on the same record. Do not cut over until the Flow matches the trigger on every path.
- L1Inventory the triggerwhat reads it
Catalog what the trigger sets, whether it runs before or after save, and every validation rule, trigger, or Flow downstream that reads those fields. The migration bug lives in that downstream list, not in the Flow.
Locate yourself on that ladder. Most migrations skip straight from L1 to a delete, which is the cliff. Every rung above the bottom subtracts a specific way the cutover breaks prod, and by the top you are deleting an inert shell that has done nothing for a quarter, which is the only safe time to delete anything.
Why before-save, and why order of execution is the whole game
Move field-setting logic to a before-save Flow, not after-save, and not a record-triggered Flow that does a DML update. Before-save Flows set fields in the same transaction with no extra DML, so they are faster than the Apex they replace, and they run at a defined point in the Salesforce order of execution. That last part is the trap the L1 inventory rung exists to catch.
Salesforce runs automation in a fixed order (Salesforce order-of-execution documentation), and the migration bug lives in the gap between where your old Apex sat and where the new Flow sits.
A field the Apex used to set late is now set early, and a downstream validation rule or another trigger that expected the old timing sees a different value at its moment. The migration bug is almost never in the Flow. It is in the timing shift between what you removed and what you added.
Concretely: say the old after-trigger stamped a Risk_Tier__c field based on Amount and a discount field, and a validation rule two steps later blocked the save when the tier was High and the approval flag was empty. Move that logic to before-save and the tier now exists before the validation runs, so records that used to slip through on the first save because the tier was still null now get blocked. Nothing about the Flow is wrong. The Flow does exactly what the Apex did, one phase earlier, and that earlier phase is enough to change which records the org accepts. You will not see this by testing the Flow in isolation. You see it only when you run the whole save path on a record that trips the downstream rule, which is why L2 shadow-diffs against the live trigger instead of trusting the Flow in a vacuum.
The lesson I keep relearning: a migration that changes only the logic is safe, and a migration that changes the logic and the phase is a timing experiment. Treat every Apex-to-before-save move as the second kind until L2 has proven it is the first.
Cutover and stub: the code that keeps a reverse gear
L3 and L4 are two lines of metadata and a gutted body. Deactivation, not deletion, is the load-bearing decision. You cannot deactivate a trigger directly in production through the UI, so you do it through metadata: set status to Inactive in the meta.xml and deploy it inside the same package that flips the Flow to Active.
<!-- OpportunityBeforeSave.trigger-meta.xml (L3: cutover) -->
<ApexTrigger xmlns="http://soap.sforce.com/2006/04/metadata">
<apiVersion>62.0</apiVersion>
<status>Inactive</status>
</ApexTrigger>
The class still exists, counts for nothing at runtime, and reactivates in one deploy if the Flow misbehaves. Then L4 stubs the body so coverage cannot collapse. The body does nothing, the test still exercises it, org-wide coverage holds above the 75% floor.
trigger OpportunityBeforeSave on Opportunity (before insert, before update) {
// L4 stub: logic migrated to Flow "Opportunity | Set Stage Fields" 2026-07.
// Trigger deactivated via meta.xml; body stubbed to preserve coverage.
// Delete in a separate cleanup release once the Flow has run clean in prod.
if (false) { System.debug('inert'); }
}
One trigger, through the five rungs
Here is a single trigger climbing the ladder, with the two numbers that decide whether a step is safe: org-wide coverage and rollback cost. Watch coverage never dip under the 75% floor, and the reverse gear never leave your hand.
| Rung | Action | Trigger status | Flow status | Org coverage | Rollback cost |
|---|---|---|---|---|---|
| L1 Inventory | Catalog fields and readers | Active | none | 81% | n/a, nothing changed |
| L2 Shadow | Build Flow, diff in sandbox | Active | Active in sandbox | 81% | discard the sandbox Flow |
| L3 Cutover | meta Inactive, Flow Active | Inactive | Active | 81% | 1 deploy: flip both back |
| L4 Stub | gut body, keep test | Inactive, stubbed | Active | 79% | 1 deploy: restore body |
| L5 Retire | delete shell, drop test | deleted | Active | 78% | code restore, so do it last |
The coverage column is the reason the order is fixed. At L4 coverage settles at 79% because the stub keeps its own lines and test in the count. Only at L5, when you delete the shell and its test, does coverage step down to 78%, still clear of the floor, and by then the Flow has run clean for a quarter so a rollback is not something you expect to need. Reverse that order, delete at L3, and the same trigger’s removal can pull coverage under 75% while the Flow is unproven, which is both a failed deploy and a lost undo in one move.
The change-set promotion checklist
Sandbox is clean. Now promote, and promote the exact thing you tested. If you shadowed the Flow live and the trigger inactive together, do not promote the Flow alone and deactivate the trigger by hand later, because now the two changes land in different states than the state you proved safe. Before you click deploy to the next org up:
| Check | Why it matters |
|---|---|
| Validate-only (check-only) deploy first | A validation failure here is free. A failure mid-deploy to prod is not. |
| Confirm trigger status is Inactive, not missing | Missing means someone deleted it; Inactive means it reactivates in one deploy. |
| Confirm the Flow is Active in the target | Inactive Flows deploy silently and do nothing. Your logic just vanished. |
| Coverage holds at or above 75% | The stub protects this; verify the number, do not assume it. |
| Reactivation change set staged | Trigger to Active, Flow to Inactive, ready to fire if prod surprises you. |
| Delete-and-replace | The five-rung ladder | |
|---|---|---|
| Removing the old code | Deleted in the cutover release | Deactivated at L3, deleted at L5 a sprint later |
| Org-wide coverage | Can drop below 75% mid-deploy | Stub at L4 preserves coverage, deploy holds |
| Rollback | Restore code, redeploy, hope tests pass | One deploy: trigger Active, Flow Inactive |
| Timing regressions | Found live, after the delete | Caught in the L2 full-save-path diff |
| What ships together | Flow now, trigger cleanup later, untested combo | The exact cluster you verified as one package |
The relative risk of each rung is worth seeing, because teams spend their attention in the wrong place. The Flow build gets the scrutiny and it is the least likely thing to break prod. The timing shift and the promotion cluster are where the incidents actually come from.
View as table
| Item | Value |
|---|---|
| Building the Flow | 10% |
| Timing / order-of-exec shift | 40% |
| Coverage on delete | 25% |
| Promoting a partial cluster | 25% |
Ship one trigger this week
The ladder is the mental model. Here is the concrete build for a single trigger, small enough to finish before Friday and safe enough that no field stops populating.
- 1
Inventory the trigger and its readers (L1)
Note what it sets, before or after save, and every validation rule, trigger, and Flow downstream that reads those fields. This list is your regression test plan.
- 2
Build the before-save Flow and shadow it (L2)
Match entry conditions to the trigger old isBefore and record filter. Run it alongside the live trigger in a sandbox and diff outputs on the same records across insert, every stage change, close won, and close lost.
- 3
Cut over through metadata, never the UI (L3)
Set the trigger status to Inactive in its meta.xml and flip the Flow to Active in one package. The class stays, so reactivation is a single deploy.
- 4
Stub the body to hold coverage (L4)
Gut the logic, keep a minimal body and its test so org-wide coverage never drops under 75%. The shell now documents what used to run here.
- 5
Promote the exact cluster, validate-only first (L5 prep)
One change set: Flow Active, trigger meta Inactive, stubbed body, test edits. Run check-only, confirm coverage, stage the reactivation set. Schedule the delete for a later release.
Why this scales to a 200-automation cleanup
The single-trigger version of the ladder is disciplined. The org-wide version is where it earns its keep. Collapsing 282 automations to 82 is not 200 heroic rewrites; it is the same five-rung loop run 200 times, and the discipline is what keeps the aggregate from becoming an incident log. If even 5 percent of a big-bang cleanup breaks prod, that is ten separate fires, each landing on a different team, each hard to trace back to which change caused it because you shipped them together. Run the loop one trigger at a time, deactivate-not-delete every time, and every regression is isolated to the one cluster you just promoted and reversible in one deploy.
Do the math on the two approaches and the ladder is not slower, it is cheaper. A big-bang cleanup of 200 automations with a 5 percent break rate is ten prod incidents, each costing a scramble, a hotfix, and a trust hit with the team whose process just stopped working. The laddered version front-loads a diff and a validate-only check per trigger and pays almost nothing on the back end, because the incidents that would have fired are caught at L2 in a sandbox where a failure costs a discarded Flow, not a support queue.
Delete nothing during the cutover. Deletion is the L5 rung, a separate boring release you run a sprint later once the Flow has held in production and you have stopped watching it. Inventory the trigger, shadow the Flow, cut over through metadata, stub the body, and leave the shell standing until the quarter proves it dead. Every rung keeps a reverse gear, so the worst a bad Flow can cost you is one deploy, and 282 automations become 82 without a single prod-down incident to show for it.
Keep reading
One email. Every week.
One email a week: a system I built or broke, with the config, the numbers, and what I would change. No roundups, no theory, unsubscribe whenever it stops being useful.
The newsletter opens soon.
Connect a provider in src/config.ts