Boundaries & Transactions
Take error handling seriously and it quietly redesigns your system. To know what can fail, you have to know where one part ends and another begins — and what must stay true across that line. Errors force you to think in boundaries.
Most design questions are easy to defer. Error handling isn't: the moment you ask "what happens if this fails halfway?" you're forced to answer "halfway between what and what?" That question is a boundary. Dealing with errors well pushes you, again and again, to carve the system into parts and be explicit about the promises between them.
Once you design the failure states, you have to draw the boundaries that keep those states honest. Failure design says what should happen. Boundaries decide where it can happen safely.
Errors Force You to Think in Boundaries #
- To handle a failure you must know whose failure it is — which means knowing where responsibilities begin and end.
- A vague boundary produces vague errors: you can't say what's broken because you never said what each part guaranteed.
- Good boundaries turn "something went wrong somewhere" into "this component failed this promise" — which is the whole game for debugging and support.
How Should I Carve Up My System? #
Let the failure questions guide the cuts:
- What can fail independently? Things that fail together belong together; things that fail apart belong apart.
- What has a single owner? Each boundary should have one component responsible for upholding its rules.
- What does each side promise? A boundary is a contract: given valid input, this side guarantees a defined result — or a defined, typed failure.
- Where does IO live? Concentrate the nondeterministic, retryable IO at the edges; keep a pure, predictable core inside.
Think in Transactions Within Those Boundaries #
Once you have a boundary, ask the transactional questions about what lives inside it:
- What must change together, atomically? Group the state that has to move as one unit. Either all of it commits or none of it does — no half-updates left behind.
- What can never diverge? Identify the invariants that must always hold. If two values must agree, they belong inside the same transactional boundary that can keep them in step.
- What is allowed to lag? Data across boundaries can be eventually consistent — but say so on purpose, and design for the window where it's stale (see Keep Informed and Carry On).
Atomicity is how you avoid the worst failure of all: the partial success that silently corrupts state. Inside a boundary, a transaction gives you all-or-nothing. Across boundaries, you don't get that for free — which is exactly why the boundaries matter.
In a checkout flow, Stripe is outside your database transaction. You cannot atomically commit "Stripe charged the card" and "our database marked the order paid" in one local transaction. Pretending you can is how you get paid-but-not-fulfilled orders.
Instead, draw the boundary honestly:
- Create a local order before sending the user to Stripe.
- Use an idempotency key when creating the Checkout Session.
- Treat the Stripe webhook as the source of payment events, not the browser redirect.
- Make fulfillment idempotent so a retried webhook cannot grant access twice.
- Run reconciliation that compares Stripe payments with local order state.
Those choices do not eliminate every failure. They make the failures representable and repairable.
The same problem shows up with file uploads. Suppose a user uploads a profile photo, report, invoice, or video. You want two things to be true:
- The object exists in S3.
- A matching row exists in your database.
It is tempting to open a database transaction, insert the row, upload the object to S3, then commit. That feels like it keeps the operation together. It does not. It just holds a database transaction open while waiting on a network call you do not control.
That creates several problems:
- The S3 upload may be slow while your transaction holds locks and a connection.
- The S3 upload may succeed, then your database commit may fail.
- Your database insert may succeed, then the S3 upload may fail.
- The request may time out while one side succeeded and the other did not.
- Retrying may create duplicate objects, duplicate rows, or conflicting state.
The rule of thumb is simple: do not hold a database transaction open across a network request to an external service. A database transaction can protect database state. It cannot make S3, Stripe, an email provider, or a carrier part of the same atomic commit.
Instead, choose an explicit pattern.
Pattern: Upload First, Then Commit
Use this when an orphaned object is cheaper than a missing object.
- Generate a stable object key, such as
uploads/{user_id}/{upload_id}.
- Upload to S3 outside the database transaction.
- Open a short database transaction.
- Insert the row that points at the object key.
- Commit.
- Run cleanup for old uploaded objects that never received a database row.
The possible bad state is clear: an object exists without a row. That is usually repairable with lifecycle rules or a cleanup job.
Pattern: Create a Pending Row, Then Upload
Use this when the user and support need to see that an upload was attempted.
- Open a short database transaction.
- Insert an
uploads row with status pending and the intended object key.
- Commit.
- Upload to S3 outside the transaction.
- Mark the row
ready when the upload succeeds, or failed when it does not.
- Periodically repair or expire rows stuck in
pending.
The possible bad state is also clear: a row exists for an object that may not exist yet. The product can represent that honestly as pending, processing, or failed.
Pattern: Direct Upload, Then Confirm
Use this for large files or browsers/mobile clients.
- Your server creates a pending upload row and a signed upload URL.
- The client uploads directly to S3.
- The client tells your server the upload finished, or S3 sends an event.
- Your server verifies the object exists and marks the row
ready.
- A cleanup job expires abandoned pending uploads.
This avoids routing large files through your application server and keeps database transactions short.
None of these patterns makes the whole operation atomic. That is the point. They pick one acceptable partial state and make it visible, bounded, and repairable. The design decision is not "how do we prevent all mismatch?" It is "which mismatch can we live with, how do we detect it, and how do we clean it up?"
Good upload tables usually include explicit state:
| State | Meaning | User-facing behavior |
pending | Upload expected, not confirmed | Show progress or "still uploading" |
ready | Object exists and row is committed | Show the file |
failed | Upload or verification failed | Let the user retry |
expired | Upload was abandoned | Hide or explain that it must be uploaded again |
That state machine is not overhead. It is the boundary made visible.
A useful shape to aim for is a pure core wrapped in an IO shell: keep the deterministic, atomic logic inside, and push the retryable, failure-prone IO to a thin outer ring.
+----------------------------------+
| IO SHELL (the edges) |
| network . disk . clock . procs |
| >> nondeterministic: |
| RETRY transient failures |
| |
| +--------------------------+ |
| | PURE CORE | |
| | deterministic logic | |
| | invariants kept atomic | |
| | >> a failure here is a | |
| | BUG: surface it, | |
| | never retry | |
| +--------------------------+ |
| |
+----------------------------------+
cross the ring = classify:
outer -> IO / transient / retry
inner -> deterministic / fix-it
The boundary between the rings is also where you classify every error (see Whose Fault Is It Anyway?): anything born in the shell is IO and may be retried; anything born in the core is deterministic and must be fixed, not looped.
Straddling Boundaries Explodes the Error Space #
Here's the cost of getting it wrong. When a single operation straddles two boundaries, its outcomes aren't the sum of each side's outcomes — they're the product.
- One boundary with 3 outcomes: 3 cases to handle.
- An operation spanning two such boundaries: up to 3 × 3 = 9 combined states, including the nasty mixed ones (A committed, B didn't).
- Span a third: 27. The number of possible error states grows combinatorially, and most of the new states are partial failures no one thought to handle.
ONE BOUNDARY 3 outcomes -> 3
+------+
| ok | -> just name the
| fail | one broken
| slow | promise
+------+
TWO BOUNDARIES 3 x 3 -> 9
straddled outcomes
A\B | ok | fail | slow
-----+------+------+-----
ok | OK | !! | ~~
fail | !! | fail | !!
slow | ~~ | !! | slow
OK = both committed (happy)
!! = PARTIAL failure (disagree)
~~ = mixed / must reconcile
+1 more boundary -> 27 states,
and nearly every NEW one is !!
The diagonal of that grid is the happy path; every off-diagonal cell is a state where the two sides disagree — a partial failure you now have to detect, explain, and clean up. A single boundary has none of these.
Every straddle also forces you to invent cross-boundary recovery — sagas, compensating actions, reconciliation jobs — machinery that exists purely to clean up messes a cleaner cut would have prevented.
Clear Boundaries Lead to Clearer Errors #
The payoff is compounding:
- Clearer errors. A failure names the boundary and the broken promise, instead of a shrug.
- Easier debugging. You can reason about one component's contract at a time, not the cross-product of everything.
- Easier support. "Payments is degraded, checkout is unaffected" is a sentence you can only say if payments and checkout are actually separate. Customers get precise answers instead of "something's wrong."
- Smaller blast radius. A well-drawn boundary is also a bulkhead — failure stays on one side.
Draw the lines with intent, keep transactions inside them, and refuse to straddle unless you truly must. The reward is a system whose failures are legible — to you, to your team, and to your customers.
Front End vs. Back End Notes #
- Front end: treat component, module, and network boundaries as contracts; keep a view's state transitions atomic so the UI never shows a half-applied update; make optimistic changes reconcile cleanly at the boundary rather than smearing across it.
- Back end: align transaction boundaries with service and aggregate boundaries; keep invariants inside a single transactional unit; make anything that must cross a boundary idempotent and reconcilable, and prefer one clear owner per piece of state.
Related chapters: Failing by Design · Error Handling Without Fear · Do or Do Not. Then Retry.