skip to content

Why does Clean Code advise signalling failures with exceptions rather than returning error codes, and when might error codes still be the better choice?

level: juniorimportance: must knowfreq 78%

answer

  1. Codes clutter call sites; exceptions clear the happy path
  2. Codes are ignorable; unhandled throws are loud
  3. Error-code enum = dependency magnet, violates OCP
  4. Exception carries message + cause + stack; -1 carries nothing
  5. Expected outcome -> Result/Optional, not throw

basics

~20 s

Error codes force every caller to check the returned value right at the call site, mixing error checks into the happy path. Exceptions move failure handling out of the main flow, and they cannot be silently ignored, so the code reads cleaner and is safer.

solid answer

~50 s

An error code is an ordinary return value (-1, false, an enum, an errno) that the caller must inspect. That has three costs: the check clutters the algorithm, the caller can forget it and continue with garbage, and the code must be propagated by hand up every frame. Exceptions separate the two concerns: the happy path stays linear, the failure path lives in a catch block, and an unhandled exception aborts the operation instead of corrupting state. Exceptions also carry context (message, cause chain, stack trace) that an integer never does. Codes still win where exceptions are unavailable or too costly: hot loops where throwing dominates the cost, languages/runtimes without exceptions, ABI/FFI and syscall boundaries, and — importantly — *expected* outcomes such as "not found" or "invalid user input", where a typed result (Optional, Result/Either) is more honest than an exception. Rule of thumb: exceptions for exceptional, unexpected conditions; values for outcomes that are part of normal business flow.

code

pseudocode · 14 lines
pseudocode
// error codes: logic drowned in checking
if (delete(page) != OK) { log("a"); return ERR; }
if (unlink(page.ref) != OK) { log("b"); return ERR; }
if (dropKey(page.key) != OK) { log("c"); return ERR; }
return OK;

// exceptions: intent first, failure once
try {
    delete(page);
    unlink(page.ref);
    dropKey(page.key);
} catch (StorageException e) {
    throw new PageDeletionFailed(page.id, e);   // wrap, keep the cause
}

go deeper

for a junior

Say: codes must be checked at every call and are easy to forget; exceptions keep the main logic readable and cannot be silently ignored. Mention try/catch/finally.

for a middle

Add the coupling argument (shared error enum recompiles everyone; new exception subtypes don't), cause chains, and that expected business outcomes should be values (Optional/Result), not throws.

for a senior

Frame it as a taxonomy decision: programmer bugs vs infrastructure failures vs expected outcomes, each with a different mechanism. Discuss throw cost, invisible control flow and the cleanup discipline it forces (finally/RAII/defer), plus boundary translation.

for a principal

Discuss it as an API contract and organizational concern: the error model is part of the published interface, must be stable and versionable, must map cleanly onto transport (HTTP status/problem+json, gRPC codes), and must be consistent across teams — plus its effect on retryability, idempotency and observability.

## The two ways a function can report failure A function that can fail has to tell its caller. There are essentially two mechanisms. **1. Error codes (also: sentinel/status returns).** The failure is encoded in the normal return value or an out-parameter: `-1`, `null`, `false`, an enum like `Status.DISK_FULL`, or a global `errno`. The caller is expected to inspect it. **2. Exceptions.** The function *throws* — it abandons its normal return path and transfers control to the nearest enclosing handler (`catch`/`except`/`rescue`) up the call stack. If nobody handles it, the program or request aborts. ## Why Clean Code prefers exceptions **Separation of concerns.** With codes, the algorithm and the error handling are braided together: ``` if (deletePage(page) == E_OK) { if (registry.deleteReference(page.name) == E_OK) { if (configKeys.delete(page.key) == E_OK) { log("deleted"); } else log("configKey delete failed"); } else log("deleteReference failed"); } else log("delete failed"); ``` The reader cannot see *what the function does* for all the checking. With exceptions, the three steps are three consecutive lines and the failure story lives once, in a catch block. Clean Code phrases this as "error handling is one thing": a function that handles errors should do nothing else, i.e. `try` should be the first word in the function and the body of `catch`/`finally` should be all that follows. **Codes are ignorable; exceptions are not.** Nothing forces a caller to look at a returned `-1`. Forgetting it means the program keeps running on a value that was never valid — the bug then surfaces far from its cause. An unhandled exception, by contrast, propagates and eventually fails loudly. Loud failure near the cause is cheaper to debug than silent corruption far from it. **Manual propagation.** With codes, every intermediate frame must check-and-rethread the code upward. That is boilerplate, and every frame is a chance to drop it. Exceptions propagate automatically through frames that have nothing useful to say. **Context.** An exception object carries a message, a type, usually a stack trace, and a *cause* (the wrapped lower-level exception). `Status.ERROR` carries nothing. **Coupling.** Clean Code's specific complaint about returning codes from a shared enum/`Error` class is that the enum becomes a *dependency magnet*: it is a module every caller and callee must import, and adding a new code recompiles/redeploys everyone. Deriving a new exception type from a base type adds nothing to the existing ones — this is the Open/Closed Principle applied to error reporting. ## The honest counter-arguments - **Exceptions are invisible control flow.** A `throw` five frames down can bypass code you assumed would run. That is exactly why `finally` / `defer` / RAII / try-with-resources exist: cleanup must be attached to the resource, not to the happy path. - **Cost.** In most runtimes, throwing (capturing a stack trace, unwinding) is orders of magnitude more expensive than returning a value. Fine for a failed request; wrong for a parse loop over ten million lines. Never use exceptions for ordinary control flow ("throw to break out of a loop"). - **Expected outcomes are not exceptions.** "User not found", "password doesn't match", "cart is empty" are *anticipated* results of a normal operation. Modelling them as exceptions makes the normal path throw thousands of times a minute and hides the real business branch in a catch block. Return `Optional`/`Maybe`, a `Result`/`Either`, or a small sealed set of outcome types. This is the modern refinement of the classic advice — the enemy was never "values", it was *untyped, ignorable* values. - **Environments without exceptions.** C, most FFI/ABI boundaries, kernel syscalls, some embedded and real-time contexts (deterministic timing, no unwinding tables), and Go by design. There, the discipline shifts to *making the code impossible to ignore* — multi-value returns with a linter that flags unchecked errors. ## Decision guide | Situation | Prefer | |---|---| | Precondition violated, programmer bug (null arg, bad state) | Unchecked exception, fail fast | | Infrastructure failure (I/O, network, DB down) | Exception, wrapped at the boundary | | Anticipated business outcome (not found, validation failed) | Typed value: `Optional`, `Result`/`Either`, sealed outcome | | Hot inner loop / performance-critical parse | Value, no throw | | C ABI, syscall, cross-language boundary | Code, checked at the boundary and converted to an exception/Result inside | ## What never to do Do not catch an exception and return a code (you paid for the exception and lost its context). Do not catch and swallow (`catch (e) {}`) — that is the silent-failure disease you were trying to escape. And do not return `null` as your error code: that pushes a check onto every caller and produces the single most common runtime crash there is.

  • If exceptions are better, why does Go deliberately use multi-value error returns instead?
    Go trades brevity for explicitness and predictable control flow: every failure point is visible at the call site, there is no invisible unwinding, and cost is constant. It compensates for ignorability with tooling (vet/linters flagging unchecked errors) and `defer` for cleanup. It's a coherent alternative discipline, not an endorsement of C-style silent codes.
  • Where should the try/catch live — deep in the call stack or at the boundary?
    Catch where you can actually do something: retry, substitute a default, translate to a user-facing response. Otherwise let it propagate. Most systems end up with one boundary handler per entry point (HTTP handler, message consumer, CLI main) that maps exception types to responses/exit codes, plus a few local catches that add context by wrapping.

Error codes are like a colleague who mumbles "by the way that didn't work" while you walk past — easy to miss and you're already several steps down the hall. An exception is the fire alarm: it stops you where you are and you cannot pretend you didn't hear it.

saying these in an interview costs you the question

  • "Exceptions are always slow, so use codes everywhere" — the cost only matters at high throw rates, not on a failed request
  • Catching an exception only to return a boolean/code, throwing away the cause
  • Empty catch blocks ("we'll log it later")
  • Using exceptions for normal branching, e.g. throwing to exit a loop or to signal 'user not found' on every miss
  • Claiming checked exceptions are the same thing as error codes — they are typed, propagate automatically, and carry cause chains

context