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 #

How Should I Carve Up My System? #

Let the failure questions guide the cuts:

Think in Transactions Within Those Boundaries #

Once you have a boundary, ask the transactional questions about what lives inside it:

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:

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:

  1. The object exists in S3.
  2. 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 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.

  1. Generate a stable object key, such as uploads/{user_id}/{upload_id}.
  2. Upload to S3 outside the database transaction.
  3. Open a short database transaction.
  4. Insert the row that points at the object key.
  5. Commit.
  6. 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.

  1. Open a short database transaction.
  2. Insert an uploads row with status pending and the intended object key.
  3. Commit.
  4. Upload to S3 outside the transaction.
  5. Mark the row ready when the upload succeeds, or failed when it does not.
  6. 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.

  1. Your server creates a pending upload row and a signed upload URL.
  2. The client uploads directly to S3.
  3. The client tells your server the upload finished, or S3 sends an event.
  4. Your server verifies the object exists and marks the row ready.
  5. 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:

StateMeaningUser-facing behavior
pendingUpload expected, not confirmedShow progress or "still uploading"
readyObject exists and row is committedShow the file
failedUpload or verification failedLet the user retry
expiredUpload was abandonedHide 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        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:

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 #


Related chapters: Failing by Design · Error Handling Without Fear · Do or Do Not. Then Retry.