Fail Before the Side Effect: Ordering Checks Around Actions You Cannot Undo

Date:

The record could never have been created — a required category did not exist yet. The system found that out only after it had already texted a one-time code to a real person’s phone.

The short answer: When a process both validates inputs and performs an irreversible action — sending a message, charging a card, provisioning an account — the checks that can still say “no” must run before the irreversible step, not after. The failure is ordinary: a routine runs its checks in whatever order the code was written, and a value that was always going to be rejected is discovered only after a one-time code has already reached someone’s phone. Reorder so everything that can fail is exhausted first, and the side effect happens only once success is certain. An irreversible action belongs at the end of the checks, not the middle — you cannot un-send what a later validation makes pointless.

Fail-before-the-side-effect is the discipline of ordering a process so that every check capable of aborting it runs before any step that cannot be undone.

What “side effect” means here

A side effect is any step that reaches outside your own transaction and leaves a trace you cannot cleanly retract: a text message or email delivered, a payment captured, an account created in another system, a webhook fired, a file shipped to a partner. Once it happens, “roll back” is no longer fully available — the message was read, the code was received, the charge showed up on a statement.

Contrast that with a reversible check: validating a format, looking up whether a category exists, confirming a value is in range. These cost nothing to fail. They can be run, and re-run, and reordered, with no consequence in the outside world. The whole technique rests on telling these two kinds of step apart and never letting a reversible check that can fail sit after an irreversible one.

A side effect is any step whose undo button is someone else’s inbox.

The bug is an ordering bug, not a logic bug

The individual checks are usually correct. The routine really does verify the category exists; it really does reject the bad value. The defect is when it does so.

Picture a create step that, in code order, verifies contact details by sending a one-time code, and only later checks that the target category is valid. For a record whose category does not exist, the outcome is fixed from the start — it will be rejected. But the rejection comes after the code has already been sent. A real person receives a verification code for an account that was never going to be created. Nothing errored incorrectly; every check did its job. They simply ran in an order that let a doomed operation touch the outside world before failing.

The checks were right. Their order was the bug.

Why it hides

Ordering bugs of this kind are easy to ship because nothing looks wrong in the common case. When the data is valid, every step succeeds and the order is invisible — the side effect fires and the operation completes, exactly as intended. The flaw only surfaces on the inputs that were going to fail, which are the minority and often the ones least exercised in testing.

So the routine passes review, works in the demo, and runs in production for a long time before the first doomed input arrives and quietly contacts someone it should never have contacted. Like many ordering hazards, it is invisible until the exact case that exposes it shows up — the same reason a validation pattern that will not compile disables a field silently until someone loads that form. The cost lands on real people, not on your error log.

How to order it

The rule is a single pass of reordering with one question asked of every step: can this step still make the whole operation fail? Everything that can goes first; the irreversible action goes last.

  1. Separate the steps into can-fail and irreversible. Format checks, existence lookups, range and uniqueness checks are can-fail. Sends, charges, provisioning, external writes are irreversible.
  2. Run every can-fail check first. Exhaust them. If any fails, abort — and nothing outside your transaction has happened yet.
  3. Perform the irreversible action only once success is certain. By the time you send the code or capture the charge, there is no remaining check that could have said no.
  4. Where two side effects are unavoidable, order them by cost and reversibility. If you must do more than one, put the most reversible or lowest-cost one first, so a failure between them does the least harm.

Applied to the opening example: check that the category exists before verifying contact details, so a record with an invalid category is rejected in silence, and the one-time code is only ever sent to someone whose account will actually be created.

The limits of the technique

Reordering removes the side effects you can foresee failing; it cannot make a genuinely irreversible action safe to attempt on uncertain input, and it cannot help when the only way to know something will fail is to try the irreversible step itself — some external systems only reveal a rejection on the real write. For those, the tools are different: a reversible pre-check or “dry run” the external system offers, an idempotency key so a retry does not double the effect, or a compensating action that undoes as much as possible after the fact. And a process with two unavoidable side effects can still be left half-done by a failure between them; ordering reduces that blast radius but does not remove it. Fail-before-the-side-effect is the first and cheapest defense, not the whole of transactional safety — and it matters most when you automate an interface that can change under you, where an action’s preconditions are never guaranteed stable.

The short version

When a process mixes checks that can fail with an action that cannot be undone — a text, a charge, an account created elsewhere — order it so every can-fail check runs first and the irreversible step happens only once success is certain. The classic bug is an ordering bug, not a logic one: each check is correct, but a doomed operation reaches the outside world (a one-time code to a real phone) before a later check rejects it. It hides because valid inputs never expose it; only the inputs that were going to fail do, and they are the least tested. Separate can-fail from irreversible, exhaust the first, and place the side effect last — and where the external system only reveals failure on the real write, reach for a dry-run, an idempotency key, or a compensating action instead.

Frequently Asked Questions

What does “fail before the side effect” mean?

It means ordering a process so that every validation capable of aborting it runs before any step that cannot be undone. A doomed operation should be rejected while all that has happened is some cheap internal checks — never after it has already sent a message, charged a card, or created something in another system. The irreversible action is placed last, after success is certain.

Why is the order of validation checks so important?

Because some steps have consequences outside your control. A reversible check costs nothing to fail and can run in any order. An irreversible step — a delivered message, a captured payment — cannot be retracted once done. If a reversible check that will fail runs after an irreversible step, the operation touches the real world before aborting, harming someone for an action that was never going to complete.

What counts as an irreversible action or side effect?

Any step that reaches outside your own transaction and leaves a trace you cannot cleanly retract: sending an email or text, delivering a one-time code, capturing a payment, provisioning an account in another system, firing a webhook, or handing a file to a partner. The test is whether “undo” is fully within your control — if reversing it depends on someone else’s inbox or ledger, treat it as a side effect.

How is this different from failing fast?

Failing fast is about detecting an error as early as possible; fail-before-the-side-effect is specifically about placing the irreversible step after every check that could abort. They align — running checks early tends to put them before side effects — but the emphasis differs: here the non-negotiable is that no can-fail step sits after an irreversible one, even if that means doing more validation up front than a pure fail-fast ordering would.

What if the only way to know something will fail is to attempt the irreversible action?

Then reordering alone is not enough, and you reach for other tools: a reversible pre-check or dry-run mode if the external system offers one, an idempotency key so a retry does not double the effect, or a compensating action that undoes as much as possible afterward. Fail-before-the-side-effect handles the failures you can foresee; these handle the ones only the real write reveals.

How do I handle a process with two unavoidable side effects?

Order them by cost and reversibility: perform the most reversible or lowest-cost side effect first, so a failure between the two does the least harm, and use idempotency keys so a retry after a partial failure does not repeat the step that already succeeded. Ordering shrinks the blast radius of a mid-process failure but does not remove it, so pair it with compensating actions for the steps that did complete.

Share post:

Subscribe

Popular

More like this
Related

How to Merge Spreadsheets by URL (and Why Our Tool Reported 5 Misses That Weren’t)

Matching two spreadsheets on a URL column is the same job as matching a stock list to a price list by product code. It goes wrong in the same two places: how you clean the code, and how you count what didn't match.

Many-to-One: Why a Mapping Backlog Outgrows Its Target Taxonomy

You're mapping a messy source vocabulary onto a clean target taxonomy. The target has about 5,600 slots, so the job must be roughly 5,600 decisions — right? The backlog is already past 8,900, and still climbing.

The Fix That Didn’t Take: When a Content-Hash Gate Skips Your Code Change

You shipped a fix to how one field is computed, re-ran the pipeline across everything, and it reported a clean run. Then the consistency check still flags those records as wrong. The fix is in the code, the rebuild "ran" — and the records never changed. Your own optimisation skipped them.

Your Dedup Scan Is Over-Counting — Because It Forgot What You Already Fixed

The report said 80 duplicate groups still to resolve. You'd been resolving them for days. You run it again after a big batch and it *still* says 80-ish. The backlog isn't stubborn — the scan is counting work you already finished.