ErrorsCONTESTEDLANGUAGE-SPECIFICPARADIGM-SPECIFIC

Exceptions, Where They Help and Where They Hide the Flow

A non-local jump is exactly right for a failure nobody local can answer, and exactly wrong for an outcome the caller was supposed to decide about. The dividing line is not a rule about exceptions.

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.

The question

Which failures deserve a non-local jump, and when has an exception become a control-flow mechanism in disguise?

The requirement

A code review has stalled. One engineer says the import job should throw RowInvalid and let the loop's handler skip the row; another says a skipped row is an expected outcome and throwing for it is abuse. Both have written large systems and neither is being unreasonable.

The obvious build

Pick a side and enforce it. Either "we do not use exceptions for control flow, ever" or "exceptions are the language's error mechanism, use them" — write it in the style guide and stop arguing.

Why it breaks

Both rules survive exactly until the first case they were not written for. "Never throw for expected failures" meets a parser five frames deep where returning a failure means threading it through five signatures that have nothing else to say about it.

How it breaks as requirements change
  • Both rules survive exactly until the first case they were not written for. "Never throw for expected failures" meets a parser five frames deep where returning a failure means threading it through five signatures that have nothing else to say about it.
  • The rule cannot be applied by a reviewer who did not attend the discussion, so it degrades into "whatever the most senior person in the thread prefers", and the codebase acquires both idioms with no boundary between them.
  • As the system grows, third-party libraries keep throwing, so the "no exceptions" codebase acquires a translation layer at every dependency — which is genuinely good design, and nothing in the rule said to build it (Anti-Corruption Layer).
  • The failure everyone actually cares about is different: not which mechanism, but a catch block so far from the throw that nobody can say what state the system is in when it runs.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

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.

Constraints
  • The language is Java for the batch tier and TypeScript for the API, so any rule has to survive both — checked exceptions exist in one and not the other (What a Framework Charges).
  • Half the codebase is library code the team does not own, and it throws whether or not the team likes exceptions.
  • Whatever is decided has to be reviewable, because the actual cost here is the argument recurring on every pull request.
Invariants
  • A failure that cannot be answered where it occurred must reach somewhere that can answer it, without being downgraded on the way (Error Boundaries).
  • The observable behaviour of the happy path never depends on an exception being thrown and caught as a normal step.
  • Any resource acquired before a throw is released, on every path, without the caller having to remember.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • The throwing code owns making the exception carry enough context to be actionable where it lands — which id, which row, which dependency.
  • The catching code owns having an actual answer. A catch that only logs and rethrows is a comment with a stack cost (Swallowed Errors).
  • The module boundary owns not letting an implementation exception escape as-is; a caller should never have to catch a driver-specific type to use your module (Leaky Abstractions).
  • The language owns unwinding: resource release on the throw path is try-with-resources, defer or RAII, not a hand-written cleanup that gets it right most of the time.
Boundaries
  • The useful line is *distance*: an exception is appropriate when the code that can answer the failure is far from the code that discovered it, and questionable when it is the immediate caller.
  • A second line is *frequency*: something that happens on a meaningful fraction of calls is an outcome, and outcomes belong in the return type regardless of what mechanism the language offers.
  • Every process, request and job boundary is a mandatory catch point, because past it the exception becomes a crash rather than a failure (Error Boundaries).

The question that actually decides it

Neither "always throw" nor "never throw" survives review, because the real variable is where the answer to a failure lives. When the code that discovered a problem also knows what to do about it, a jump is pointless indirection. When the answer is nine frames up, a return value makes nine functions declare something they do not care about.

The second variable is frequency, and it is the one that settles the import-job argument in the requirement above: a skipped row on one percent of a ten-thousand-row file is a normal outcome of importing, and modelling it as an outcome makes the success and skip counts fall out of the design instead of being reconstructed from a catch block.

A failure has just been discovered. What carries it?

Should this failure be thrown, or returned?

Return it as a value

when The immediate caller is meant to decide, or the failure happens often enough to be a normal outcome — a decline, a validation error, a lookup miss, a skipped row

cost Every frame between here and the handler mentions it. In deep call chains that is real noise, and in a codebase where the surrounding libraries throw, it means adapters at both shores (Result Types).

Throw it

when No local answer exists and the handler is genuinely distant — a malformed file discovered five frames into a parser, a dependency that is down, our own bug

cost The control flow leaves the text. A reader at the throw site cannot see where execution resumes, and nothing prevents a broad catch upstream from absorbing it as something else.

Throw, and catch at a declared boundary

when You want the clean happy path but need the handling to be reviewable — one documented catch point per operation, per request, per job

cost Coarse: it permits throws two frames from their catch, which is the case the other camp objects to most, and it depends on the boundary being enforced by review rather than by the compiler.

Neither — make it unrepresentable

when The failure exists only because a type is too wide: a string that should have been a parsed value, a state combination that should not exist

cost A parsing step and a type nobody had to write before, plus the discipline to keep the narrow type narrow (Making Illegal States Unrepresentable).

Checked exceptions: the experiment worth understanding

LANGUAGE-SPECIFICOnly Java has checked exceptions in wide use; C#, Python and JavaScript have nothing in the signature at all, so the visibility this example is about has to come from a comment or a type in the return position instead. The lesson transfers — the failure mode is catch (Exception) in any language — but the compiler help does not.

Java's checked exceptions are the most-derided feature in mainstream error handling and also the closest thing the industry has to a controlled experiment on this argument. They make the invisible dependency visible in the signature — which is exactly what critics of exceptions ask for — and the profession largely rejected them.

The reason for the rejection is worth knowing, because it is the same cost Result charges: every intermediate frame must declare a failure it does not handle. What people did in response was worse than either option, and the pattern below is the one to recognise in review.

The declared dependency, and the escape hatch
1// visible in the signature: callers must decide or declare
2Payment charge(Order o) throws DeclinedException, ProviderDownException { ... }
3
4// what teams actually wrote, under deadline:
5try {
6 return charge(order);
7} catch (Exception e) { // absorbs NPE too
8 throw new RuntimeException(e); // now invisible again
9}
10
11// the version that keeps the property:
12try {
13 return charge(order);
14} catch (ProviderDownException e) {
15 return Outcome.retryLater(e.retryAfter()); // an actual answer
16}
17// DeclinedException is not caught here: the handler is upstream,
18// and nothing in between pretends to know what to do with it.

The middle block is the thing to catch in review, in any language. It converts a checked, visible failure into an unchecked, invisible one and takes every bug with it — the mechanism was fine and the usage removed its only advantage.

The smell, and the case where it is correct

One pattern accounts for most of the damage attributed to exceptions, and it is not throwing — it is catching too widely and continuing. It is worth naming precisely, because the fix is narrow and the pattern has a legitimate twin that looks identical from three feet away.

smellThe optimistic catch

looks like A try wrapping a block that contains both a call to something external and a chunk of your own logic, with a catch that logs and continues to the next item.

suggests Nobody has decided which failures are expected. The block catches the provider timeout it was written for, and also every NullPointerException, every KeyError and every impossible-state assertion in the logic that happens to sit inside the same braces — so defects are quietly reclassified as data problems.

fix Shrink the try to the single call that can fail expectedly, name the exception type, and move your own logic outside it. Then assert in a test that an injected bug escapes the handler rather than being counted as a skip.

when this is fine A batch processor that must complete despite individual failures, where the try wraps *only* the external call, the caught type is specific, the failure is recorded per item with enough detail to reprocess, and the run reports a skip count that someone actually looks at. That is not a swallowed error; that is Partial Failure designed on purpose.

How to build it

Most important first.

  • Use an exception when the answer is elsewhere: a bug, an unrecoverable dependency failure, or a deeply nested parse failure whose only sane handler is the operation boundary.
  • Use a return value when the immediate caller is supposed to decide: a decline, a validation failure, a lookup miss (Result Types).
  • Never catch broadly to keep going. catch (Exception e) around a block that includes your own logic converts bugs into handled outcomes and is the single most damaging pattern in this module.
  • Translate at module boundaries so callers depend on your vocabulary rather than your dependencies'.
  • Put the context on the exception, not in a log line beside it — the log line is at the throw site and the decision is at the catch site (Debuggability by Design).
  • Write the rule as a boundary rather than a ban: "exceptions do not cross this module's public interface" is reviewable; "no exceptions for control flow" is a debate.

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.

Cost of the next change
  • Adding a new failure to a throwing function costs nothing at the throw site and nothing at compile time anywhere else — which is cheap today and is precisely why nobody notices that a caller two levels up is now wrong.
  • Changing where a failure is handled costs a search for every throw site that could reach the new handler, and there is no tool that answers it exactly. In a codebase with a clear boundary rule that search is one module; without one it is the whole tree.
  • Moving a failure from thrown to returned costs every signature between it and the handler, which is why the choice is worth making when the function is written (The Cost of Change).
  • The cheap change under this design is adding a *handler*: a new catch at an existing boundary is local and does not touch the domain at all.
What the recommended approach costs
  • Exceptions keep the happy path clean, and the price is that the control flow is not in the text — a reader cannot see where execution goes without knowing the whole call stack.
  • A boundary rule is reviewable but coarse: it permits a throw two frames from its catch inside a module, which is the case the other side objects to most.
  • Translating at boundaries costs wrapper types and stack depth, and the diagnostic quality of a four-level cause chain is genuinely worse than the original trace.

What can go wrong

Failure modes
  • A broad catch swallows a NullPointerException from your own code and reports it as a skipped row, so a data-corrupting bug shows up as a slightly lower import success rate for months.
  • The catch is so far from the throw that the handler cannot tell what half-finished state it inherited — the classic case being a partially-written aggregate with no transaction around it (Consistency Boundaries).
  • Exceptions used per-row in a hot loop turn a linear import into something dominated by stack capture, and the fix is a redesign rather than a tuning flag (Premature Optimization, Reclaimed cuts the other way here: this one is measurable).
  • The mitigation fails too: a strict "translate at every boundary" rule produces four wrapper exception types for one underlying failure, and the stack trace that would have diagnosed it is now three cause levels down.
Dependencies, and their direction
  • A throw creates an invisible dependency from the throwing code to whatever catches it, which no tool draws and no signature declares — that invisibility is the entire cost of the mechanism (Local Reasoning).
  • Checked exceptions make that dependency visible at the cost of propagating it through every signature, which is the same trade Result makes with different syntax.
  • Catching a third-party exception type couples you to that library at the catch site, which is usually far from the import that introduced it (Do We Need a Package for This?).
Misreads
  • "Exceptions are for exceptional situations." True and useless, because "exceptional" is not defined anywhere and everyone applies it to their own case. The operational questions — who can answer this, how often does it happen, is the caller supposed to decide — are answerable.
  • "Using exceptions means you cannot type your errors." Java's checked exceptions type them precisely; the complaint people actually have is about the propagation tax, which Result also charges. Judge the mechanisms on enforcement and distance, not on folklore.
  • "Never catch and continue." Sometimes continuing is exactly right — a batch import that fails one row of ten thousand should absolutely continue. The rule is that continuing must be a decision about a *known* failure, not a blanket absorption of everything (Partial Failure).
  • "Wrap every exception at every layer." Each wrap adds a frame and loses immediacy. Wrap where the vocabulary genuinely changes, which is at module boundaries and nowhere else.
Smells this explains
  • swallowed-errors
  • shotgun-surgery

Testing it, and how it ages

What to test, and at which boundary
  • Test that the boundary catch turns each expected exception into the intended outcome, with the domain stubbed to throw.
  • Test that a deliberately injected bug — a thrown TypeError — is *not* absorbed by that boundary as a normal failure. This is the assertion that keeps broad catches honest (Testing as Design Feedback).
  • Test resource release on the throw path: acquire, force a throw, assert the pool is not leaking (Where a Test Must Be Real).
  • Property or fuzz the parser layer if that is where throws are deep, since the interesting cases are the inputs nobody imagined (Property-Based Testing).
How this design ages
  • Exception hierarchies grow faster than they are pruned, because adding a subclass is a one-file change and deleting one requires proving nothing catches it.
  • The pressure that eventually forces a change is asynchrony: once work moves onto queues and workers, an exception no longer has a caller to propagate to, and the failure has to become data anyway (What Changes at the Network Boundary).
  • A codebase that has settled on a boundary rule ages well; one that settled on a ban acquires a second, unofficial idiom in the modules where the ban was impractical.

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.

  • CONTESTEDThe strongest case for exceptions: they keep the happy path readable, they do not force forty intermediate frames to restate a failure none of them handles, and in practice most failures are handled at exactly one boundary anyway — so a return-value discipline charges every frame to buy enforcement at one. The strongest case against: the jump target is invisible at the throw site and at every frame in between, so a reader cannot determine control flow from the text, and a broad catch silently converts defects into outcomes. Both are correct about real codebases; the disagreement is empirical, about how often a failure genuinely has a distant handler, and nobody has measured it.
  • LANGUAGE-SPECIFICJava has checked exceptions, so the propagation tax and the visibility are both real there; C# and Python have neither, so a thrown failure is invisible in every signature; Go has no exceptions for ordinary failure and a panic genuinely means a bug, which makes the boundary this lesson argues for a language rule rather than a team one. The same advice is enforced by the compiler, by convention, or not at all depending on where you are standing.
  • PARADIGM-SPECIFICIn an OO codebase the framework, the ORM and the HTTP client all throw, so exceptions are the ambient idiom and swimming against it costs adapters everywhere. In a functional codebase the ambient idiom is a value, and throwing breaks composition with everything the codebase already does — the same code is idiomatic in one and foreign in the other.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

API Designerror-model
Domains that do not exist yet
  • Programming Languages & Runtime Internals — what a throw actually costs, how stack unwinding interacts with resource release, and why capturing a stack trace per row turns a linear loop into something else.
  • Testing & Reliability Engineering — injecting a defect and asserting it escapes the handler is a fault-injection technique this lesson borrows and that domain owns.