Throw where you understand the failure; catch where you understand the context.

Principles

  1. Throw only for failure. An outcome the caller should expect, and the code understands, is a returned value. An exception means the code met something it can’t make sense of.
  2. Throw where you understand the failure, catch where you understand the context. An exception rises to the first layer that can act on it meaningfully. Every layer below lets it pass.
  3. Every catch is a deliberate decision, and it can name its kind. Translate and rethrow, handle and continue, log and rethrow, respond and stop, or compensate and rethrow. A catch that’s none of these is hiding the failure.
  4. A boundary translates the failures of what it hides. A layer that hides a mechanism hides that mechanism’s exceptions too. It rethrows them as a small set of its own types, with the original kept as the cause.
  5. Few exception types, each with one handler. Group failures by what the handler will do, not by where they came from. A type exists only if something catches it differently; a shared base only if something catches the base.
  6. Failures of meaning and failures of mechanism stay apart. Mechanism failures (I/O, database, network) may be transient. Meaning failures (bad input, an unreadable record) aren’t, and one hierarchy for both blurs how each should be handled.
  7. Catch narrowly. A specific type, or a specific condition, in the layer that understands it. A catch-all is acceptable only to clean up and rethrow.
  8. Log once, where the failure is handled. Logging at every layer on the way up records one failure several times over.
  9. Never expose internals across a trust boundary. What goes back to the outside world says what went wrong in its own terms. The details stay in the log.
  10. A caller’s mistake and an impossible state are different things. A bad argument is the caller’s bug and is reported as such. A state your own code should never reach means your code is wrong; say so plainly, not as bad input.
  11. Some exceptions are nobody’s to catch. Cancellation and resource exhaustion propagate untouched: never declared, caught or wrapped.
  12. Exception flow is visible and enforced. Each member declares what can escape it, and tooling checks the declarations. Where a library’s documentation is wrong, correct the tooling’s view of it and record why. A warning is never silenced by adding a catch.

Outcome values

What the caller should expect is returned, not thrown; this is how principle 1 is carried out.

  1. An expected outcome is part of the signature. If a caller must be ready for “not found”, “already exists” or “precondition failed”, the return type says so. Callers shouldn’t learn about it from documentation or a catch.
  2. Outcomes are a closed set. A result has a fixed list of cases: success, carrying its payload, and one case per refusal, each carrying what it needs. Nothing outside the set can arrive.
  3. Handling is exhaustive. A consumer deals with every case. Adding a case breaks every consumer’s build until it’s handled, enforced by the language or an analyzer rather than by review.
  4. Refusals are named by meaning. A refusal is phrased in the domain’s terms, not in how it was detected. The outer edge then maps each one to its response in a single place.
  5. The line is understanding. If the code knows what the outcome means and the caller can act on it, return it; if the code can’t make sense of what happened, throw. The same condition can fall either side in different layers: “no such row” is an outcome to a query and may be corruption to a loader.
  6. Values don’t smuggle exceptions back. No result carries an exception for someone to rethrow later, and no generic error string stands in for a named case. Either undoes what the value is for.

Async and cancellation discipline

Every failure stays on the direct call path, where readers and static analysis can both follow it. This carries out principle 12.

  1. Cancellation flows down, never caught. Every asynchronous member takes a cancellation token and passes it to everything it calls. Cancellation propagates as an exception no layer declares, catches or wraps.
  2. Tasks are awaited where they’re made. A task goes straight into an await or is returned directly. It’s never kept in a field, a collection or a local for later, so its failure stays on the call path.
  3. No fire and forget. A task whose failure nobody observes is a failure silently dropped. Background work belongs to an owner that runs it in a loop and handles its failures at that loop’s own outer edge.
  4. Delegate hand-offs are accounted for. Where work crosses into a scheduler or a callback as a delegate, the site states which exceptions the delegate can throw and where they end up. Static analysis loses a delegate once it’s handed on, so the account lives at the hand-off.
  5. Mechanical where possible. An unawaited task, a token not passed on and a discarded value task are build errors wherever an analyzer can detect them. The rest rests on the rule above, with a comment at each site.

The outer edge

Every failure ends in exactly one place, which responds and stops; this carries out principles 8 and 9.

  1. One edge per kind of failure. Request processing, startup and each background loop has its own outer edge. Every exception type is caught at exactly one of them, so “where does this end up?” has one answer.
  2. The edge logs; nothing else does. It records the failure once, at error level, with the full exception and enough about the operation to find it again. Layers below add context by wrapping.
  3. The response is built from known parts. It carries a fixed error code and a way to find the matching log entry, such as a request id. Never the exception’s message, which can carry paths, queries or internal state.
  4. Mechanism and meaning answer differently. A mechanism failure says “internal error” or “try again later”; a meaning failure says what the caller did wrong. Refusals from outcome values map one to one to their responses.
  5. Startup failures stop the process with a message. A failure before the service is ready names the setting or resource at fault and exits non-zero. No half-started service, and no stack trace in place of a message.
  6. Respond and stop is the only kind of catch at an edge. An outer edge catches only the types meant to end there, and answers each with its own response. Anything else is a bug: it passes through to the host, which logs it as unhandled and answers 500.

Enforcing exception flow with Tetractic

In C#, Tetractic.CodeAnalysis.ExceptionAnalyzers makes principle 12 a build error, and its configuration carries principles 10 and 11 as well.

C# has no checked exceptions: nothing in the language says what a method can throw. The nearest thing is the <exception cref> tag in XML doc comments, which is unchecked documentation. Tetractic is a set of Roslyn analyzers that treats those tags as declarations and checks them, which gets most of what checked exceptions offer without changing the language. It’s a development dependency only, adds nothing to the build output, and is licensed LGPL-3.0.

Its code fixes only ever add a declaration. Analyzers that offer to wrap a call in a try/catch generate exactly the catches principle 3 rules out.

  1. What it does. For every member, it works out which exceptions can escape: its own throw statements and whatever the members it calls document. It checks each one is declared with an <exception cref> tag. The declarations become the contract, and the compiler keeps them honest.
  2. Declaring is the fix. A diagnostic is resolved by declaring the exception (it really does escape), catching it at a layer that understands it with one of the five kinds of catch, or correcting what the tool believes about a library. Never by a catch whose only job is to silence the rule. The tool is there to show what escapes, not to push code into catching it.
  3. The ripple shows how far a failure travels. Declaring an exception low down makes every caller declare it or deal with it. That spread pushes code to translate at boundaries rather than pass many types upward.
  4. An implementation throws no more than its contract. A member may not declare an exception its base or interface member doesn’t. Substitutability applies to failures too: code written against the interface is never surprised by an implementation. This is what makes translation at a boundary compulsory, and why an abstraction ends up owning its own small set of exception types.
  5. The ignored list: never declared. Failures that can’t meaningfully be declared or handled propagate untouched: cancellation, running out of memory or stack, impossible states (principles 10 and 11).
  6. The intransitive list: caller mistakes stop at the member that detects them. Argument-style exceptions are declared by the member that validates, but not inherited by its callers, since a correct caller never triggers them (principle 10).
  7. Library documentation is corrected in one reviewed file. An adjustments file adds exceptions a library really throws but doesn’t document, and removes ones that can’t happen as the library is used. Each line carries a comment saying why. The tool then sees reality, with no change at the call sites.
  8. Delegates are the blind spot, so they’re accounted for where they’re made. The tool can’t follow a lambda or method group once it’s handed elsewhere. Two rules flag any delegate that may throw beyond what its delegate type declares. Each such site either stays within the type, or carries a comment naming where its throws are covered.
  9. Errors, and only in production code. The rules fail the build. Tests turn the declaration rules off, because assertions and the exceptions under test are meant to escape.
  10. Its limits. It sees direct calls and documentation. It can’t see tasks stored and awaited elsewhere, which is why Async and cancellation discipline keeps every task on the call path. The discipline is the thing; the tool enforces the part a machine can check.

Wiring

  • A package reference, private to the build.
  • ExceptionAdjustments.txt registered as an additional file: one line per correction, <member id> +<exception type id> or -<exception type id>, each with a comment giving the reason.
  • Two .editorconfig keys: dotnet_ignored_exceptions (point 5) and dotnet_intransitive_exceptions (point 6).
  • A severity per rule: error for production code, off for the declaration rules in tests.

Rules

RuleFlags
Ex0100A member may throw an undeclared exception
Ex0101A property or event accessor may throw an undeclared exception
Ex0103An implicit constructor may throw an undeclared exception
Ex0104A member initializer may throw an undeclared exception
Ex0105An iterator may throw an unexpected exception during enumeration
Ex0120A delegate created from a member may throw beyond what its delegate type declares
Ex0121A delegate created from a lambda may throw beyond what its delegate type declares
Ex0200A member declares an exception its base or interface member doesn’t
Ex0201An accessor declares an exception its base or interface accessor doesn’t