Swallowed Errors
catch {} is the visible version. The interesting one is an interface that can only return success or failure, so the code that half-worked has nowhere honest to put the truth.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
Why does a failure get silently absorbed, and what does that tell me about the interface it was absorbed inside?
A customer says they never got the invoice email for an order that the system reports as fully completed. The logs show order.completed. There is no error anywhere, and the email was never sent.
Wrap the fragile parts so one failing step does not take down the whole operation. The email is not important enough to fail an order over, so catch around it, log a warning, and carry on. Everyone has written this and it is usually the right instinct.
The instinct is right and the mechanism throws away the one fact that matters. "Order completed, email not sent" is a real, common, legitimate state, and after the catch it is indistinguishable from "order completed" — so nothing downstream can retry it, report it or count it.
- The instinct is right and the mechanism throws away the one fact that matters. "Order completed, email not sent" is a real, common, legitimate state, and after the catch it is indistinguishable from "order completed" — so nothing downstream can retry it, report it or count it.
- As requirements grow, the warehouse notification joins the email inside the same protective catch, and now the boolean means one of four things. The interface did not change, so no caller noticed.
- A
TypeErrorin the invoice-rendering code lands in that same catch, so a defect presents as an occasional missing email and survives for months. The catch was written for a flaky SMTP server and absorbs everything (Exceptions, Where They Help and Where They Hide the Flow). - The warning log seemed like a safety net, and at ten thousand orders a day it is a stream nobody reads. A log line is not a place to put a fact you need to act on (Logging at Boundaries).
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The completion step does four things — charge, persist, email, notify the warehouse — and the caller receives one boolean.
- Two of the four are genuinely optional to the business and two are not, but the interface has never said which is which.
- Retrying the whole operation is unsafe because the charge is not idempotent yet, so "just retry on failure" is not available as a fix (Idempotency by Design).
- What the system reports as done must be done. A completed order means the charge settled and the record is durable.
- Any step that did not happen is recorded somewhere a human or a job can find it, in a form that supports doing it later.
- A defect is never absorbed as an expected outcome, on any path (Error Modeling).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The operation owns reporting what it actually did — not a boolean, an outcome that can name the parts.
- The caller owns deciding whether a partial result is acceptable, which it cannot do if the interface hides the parts (Partial Failure).
- Something durable — an outbox row, a job, a flagged record — owns the unfinished work, because a log line owns nothing (The Transactional Outbox is the backend mechanism).
- Whoever writes a catch owns naming the exception type and proving in a test that a defect is not caught by it.
- The line is between steps that may fail without invalidating the operation and steps that may not. That line is business knowledge and belongs in the operation's return type, not in a catch block's scope.
- A second line separates the operation from the delivery of its optional effects: once the email is an outbox row, the operation boundary ends before the SMTP server, which is why the flakiness stops mattering (Effect Boundaries).
- The narrowest useful boundary is the
tryblock itself: it should contain exactly the call that can fail expectedly, and none of your own logic.
The smell, and the case where it is right
Naming this precisely matters because the reflex correction — "never catch without rethrowing" — is wrong often enough to be ignored, and once a rule is ignored it stops protecting the cases where it was right.
The distinguishing question is not whether the code continues. It is whether anything, anywhere, can find out that it continued.
looks like An empty catch; a catch that logs at warn and continues; an unawaited promise; an ignored return value; or — the version with no catch block at all — an operation that returns a boolean when four things happened.
suggests The interface cannot express the state that actually occurred. Somebody needed "done, except for the email" and the only values available were true and false, so the truth went into a log line or nowhere. The catch is the symptom; the impoverished return type is the design failure.
fix Widen the return type first, so partial success is sayable. Move the optional effect behind a durable record. Then shrink the try to the one call that can fail, name the exception type, and add a test asserting a defect escapes it.
The interface is the bug
It is worth looking at the two versions side by side because the catch block is nearly identical in both. The difference is what the function is able to say when it returns, and that difference is the whole lesson.
The second version has not become more reliable. The email still fails at the same rate. What changed is that the failure is now a value the caller receives, a row a job can find, and a number a dashboard can show — three consumers where before there were zero.
1// before: one boolean for four steps2async function complete(order: Order): Promise<boolean> {3 await charge(order)4 await save(order)5 try { await email(order) } catch (e) { log.warn('email failed', e) }6 try { await notifyWarehouse(order) } catch (e) { log.warn('wms failed', e) }7 return true // <- the lie8}9 10// after: the outcome can say what happened11type Completion = {12 charged: ChargeId13 saved: true14 deferred: ('invoice-email' | 'warehouse-notice')[] // durable, retryable15}16 17async function complete(order: Order): Promise<Completion> {18 const charged = await charge(order)19 await tx(async (t) => {20 await save(order, t)21 await outbox.enqueue(t, ['invoice-email', 'warehouse-notice'])22 })23 return { charged, saved: true, deferred: ['invoice-email', 'warehouse-notice'] }24}No try survives in the second version, and that is not because the email became reliable — it is because sending it is no longer part of this operation. The failure moved to a place that has a retry story, which is what "designing for partial success" actually means in practice.
Un-swallowing without breaking production
Deleting catches in a running system is how teams turn a quiet problem into a loud one and then revert the whole change. The order below exists because each step is safe only after the previous one, and step one is deliberately not a code change.
- 1Measure first
Add a counter inside the existing catch before changing anything. You now know whether this fires eleven times a day or eleven thousand.
fails by Skipping it, and discovering the rate only after the catch is gone and the pager is going off.
- 2Widen the return type
Make partial success expressible, with callers still ignoring the new field. Nothing behaves differently yet.
fails by Changing behaviour in the same commit, so a rollback has to undo both.
- 3Give the failure a durable home
Outbox row, flagged record or job, written in the same transaction as the primary effect.
fails by Writing it outside the transaction, which produces a new class of inconsistency to replace the old one (The Dual Write Problem).
- 4Narrow the try
Wrap only the external call; name the exception type; move your own logic out of the block.
fails by Leaving domain logic inside, so defects are still absorbed and the fix has changed nothing that matters.
- 5Assert the defect escapes
A test that injects a
TypeErrorinto the step and asserts it propagates rather than being recorded as a deferred item.fails by Testing only the expected failure, which passes identically under the old broad catch.
- 6Consume the record
A retry job and a dashboard count. Now the failure has an owner.
fails by Stopping at step five, which leaves a table filling with work nobody does.
- 7Delete the catch
Only now, and one at a time, with the counter from step one confirming the rate did not move.
fails by Doing this first, which is the version that gets reverted.
Steps two and three are the design change; the rest is the safety around it. If the schedule only allows two of the seven, do one and three — measurement and a durable home — and leave the catch in place. That combination is already better than the state you started in.
How to build it
Most important first.
- Change the return type before touching the catch. If the operation can partly succeed, say so:
{ charged, persisted, emailQueued, warehouseNotified }or an outcome with a list of deferred steps (Result Types). - Move optional effects out of the synchronous path entirely. An outbox row inside the same transaction as the order turns "the email failed" into "the email has not been sent yet", which is a state with a natural resolution (The Transactional Outbox is the backend mechanism).
- Shrink every
tryto the single external call and name the caught type. Your own logic goes outside the block (Exceptions, Where They Help and Where They Hide the Flow). - Record deferred work durably and count it. The metric "orders completed with unsent email" is what turns an invisible failure into an operable one (Debuggability by Design).
- Where a swallow is genuinely correct — a best-effort analytics ping — say so in the code with a named helper like
fireAndForget, so the intent is reviewable and the pattern is searchable. - Then delete the catches, one at a time, behind the characterization tests that pin current behaviour (Characterization Tests).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Before: adding a fifth step to completion costs another entry in the protective catch and no interface change, which is cheap today and adds another meaning to the same boolean — the cost is deferred and paid by whoever debugs it.
- After: adding a fifth step costs a field in the outcome type and a compile error at every caller, each of which is a genuine decision about whether that step is optional. Expensive on purpose, and proportional to how many people the answer matters to.
- Retrying a specific failed step becomes a cheap change for the first time: the durable record already exists, so it is a job, not an archaeology project.
- What stays expensive: a step that is optional for one caller and mandatory for another. The outcome type can express it, but somebody still has to decide per caller, and that is a domain conversation the design surfaces rather than solves.
- A richer outcome type pushes work onto every caller, including the three that genuinely did not care and now have to say so.
- An outbox plus a reconciliation job is real operational machinery for what used to be one line — it needs monitoring, a backlog alert and someone who understands it at 3am.
- Making partial success explicit makes the system look worse on a dashboard, because failures that were previously invisible are now counted. That is correct and it is a genuine political cost.
What can go wrong
- The catch is removed and the operation now fails entirely when SMTP is down — strictly worse than the swallow, and the reason the swallow existed. The return type has to change before the catch comes out.
- The outcome type gains fields nobody reads, so the partial state is expressible and still invisible. Expressibility without a consumer is decoration.
- The outbox is added but the reconciliation job is not, so unsent emails accumulate in a table and the failure has been relocated rather than fixed.
- The mitigation fails on its own terms:
fireAndForgetbecomes the new dumping ground, wrapping calls that were never best-effort, and within a year it iscatch {}with a better name.
- The caller gains a dependency on the richer outcome type, which is the point: the coupling makes the partial state visible instead of hidden.
- The operation loses its dependency on the SMTP client and gains one on the outbox, which is local, transactional and testable (Volatile Dependencies).
- A reconciliation job depends on the durable record of deferred work. That job is new work and it is the actual cost of this design.
- "Never catch and continue." Continuing is often exactly right. The failure is not continuing — it is continuing while reporting unqualified success, which is a lie the interface forced (Partial Failure).
- "Log it and it is not swallowed." A log line no dashboard aggregates and no job consumes is a swallow with extra disk usage. The test is whether anything downstream can act on it.
- "The fix is to remove the catch." Removing it without changing the return type converts a silent partial success into a loud total failure and will be reverted within a day. The type change comes first.
- "An empty catch is always a bug." Occasionally it is correct — a best-effort cache warm, an analytics beacon, a cleanup in a shutdown path that must not throw. What is never correct is an empty catch without a comment saying which of those it is (Comments).
- swallowed-errors
- primitive-obsession
Testing it, and how it ages
- Inject a failure into each optional step and assert the outcome *names* it — the assertion is on the returned value, not on a log line (Testing as Design Feedback).
- Inject a
TypeErrorinto the same step and assert it propagates. This is the test that distinguishes a designed partial success from a swallow. - Assert the durable record exists after a partial success, in an integration test with a real transaction, since the whole guarantee is atomicity with the order write (Where a Test Must Be Real).
- A repository-wide lint or grep for empty catch blocks in CI, treated as an error with an explicit allowlist. It is crude and it catches the regression.
- The outcome type accumulates fields as the operation accumulates steps, and at around six it is telling you the operation is really two operations (Single Responsibility, Carefully).
- The natural next step is moving the optional effects to events, at which point the operation returns quickly and the partial state becomes an eventual-consistency question rather than a return value (Commands vs Events is the backend framing).
- It stops being right when every step becomes mandatory — a regulated flow where a missing notification is a compliance failure — and the correct design collapses back to all-or-nothing with a transaction (Consistency Boundaries).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALAn interface that cannot express what actually happened forces the code to lie, and that is true of a boolean return in any language, a 200 with no body over HTTP, and a job that exits zero having skipped half its work.
- LANGUAGE-SPECIFICIn Go an ignored
erris visible as_at the call site and linters flag it; in JavaScript an unawaited promise swallows a rejection with no syntax at all, and in Python a bareexcept:also catchesKeyboardInterrupt. The design failure is identical; how loud the code is about it varies enormously, and JavaScript is the quietest. - DOMAIN-SPECIFICIn a regulated flow — a payment confirmation, a clinical record — there are no optional steps, so partial success is not a state to model but a state to prevent with a transaction. The advice inverts: the correct design there is all-or-nothing, and an outcome type listing deferred steps would be a compliance hazard rather than an improvement.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — a counter on a deferred-work table is the difference between a silent failure and an operable one, and choosing what to alert on belongs to that domain.