Designing error types that scale: thiserror, anyhow, and the architecture in between
The thiserror-vs-anyhow question is really an architecture question. Here's how to design error types that survive production — context, source chains, and the library/application boundary — from the architecture, developer, and DevOps angles.
On this page
# The Problem
It’s an incident review. A checkout request failed in production and the only trace of it in your logs is this:
ERROR: internal server errorThat’s it. No cause, no context, no chain. The on-call engineer spent forty minutes bisecting code paths to discover that a payment gateway’s DNS lookup timed out — a fact the program knew at the moment of failure and then threw away. Somewhere between the socket and the log line, every useful detail got flattened into a five-word string.
This is the most common self-inflicted reliability wound in Rust services, and it’s almost always traced back to a decision that felt trivial at the time: “should I use thiserror or anyhow?” The community has a crisp rule of thumb — thiserror for libraries, anyhow for applications — but treating it as a crate beauty contest misses the point. Error handling is an architecture decision. Design the boundary well and that log line reads like a sentence. Design it badly and you get “internal server error” at 3 a.m.
# The Two Crates, and Why It’s Not Either/Or
The rule of thumb exists for a good reason, so let’s state it precisely:
thiserrorderives typed, matchable error enums. Use it when the caller needs to branch on the failure — retry on a timeout, return 404 on not-found, surface a validation message. Libraries want this so consumers can react.anyhowgives you one opaque, context-carrying error type. Use it when the caller just needs to report the failure — log it, show it to an operator — not branch on it. Applications want this at the top level.
The mistake is reading that as a fork in the road. Mature codebases use both, at different layers. Your domain crates speak thiserror so callers can match; your main.rs and handlers speak anyhow so the top level stays clean. The skill is designing the seam between them.
For any given error, ask: will the caller behave differently depending on which failure it was? If yes → an enumerable type (thiserror), so they can match. If no, they’ll just report it → an opaque type (anyhow). This one question resolves ~90% of “which crate” debates.
# The Architecture Perspective
Picture your service as layers: domain crates at the bottom, an infrastructure/adapter layer in the middle, and the HTTP boundary at the top. Errors flow upward through these layers, and the architectural job is to decide what error type each layer exposes and how the cause chain survives the trip.
flowchart TD
subgraph Domain["Domain / library crates"]
A["thiserror enums<br/>(typed, matchable)"]
end
subgraph App["Application: main.rs / handlers"]
B["anyhow::Error + .context()<br/>(opaque, contextful)"]
end
A -->|"#[from] / #[source]<br/>preserves the chain"| B
B --> HTTP["IntoResponse:<br/>map variant → status code"]
B --> LOG["structured log:<br/>full source chain"]
classDef lib fill:#059669,color:#fff,stroke:#065f46;
classDef app fill:#2563eb,color:#fff,stroke:#1e40af;
classDef out fill:#d97706,color:#fff,stroke:#92400e;
class A lib;
class B app;
class HTTP,LOG out;The load-bearing idea is the cause chain. Every error should carry a link to the error beneath it (#[source] / #[from]), so that when you finally render it at the top, you get the whole story — “failed to place order → charge payment → connect to gateway → DNS timeout” — not just the last frame. A stack trace tells you where; the context chain tells you why.
The second architectural decision is at the HTTP boundary: your typed domain errors map to status codes there (validation → 400, not-found → 404, gateway timeout → 502), while the rich internal chain goes to your logs and never leaks to the client. Clients get a clean contract; operators get the detail.
A library’s error enum is as much a public interface as its function signatures. Adding a variant, changing one, or collapsing typed errors into an opaque blob are all breaking changes to how consumers handle failure. Design the enum deliberately, and keep it stable.
# The Developer Perspective
Here’s the boundary in code. First, a domain library models its failure modes as a typed enum and preserves causes with #[source] / #[from]:
use thiserror::Error;
#[(Error, Debug)]
pub enum OrderError {
#[("order {0} not found")]
NotFound(OrderId),
#[("payment declined: {reason}")]
PaymentDeclined { reason: String },
// #[from] auto-converts and keeps the underlying error as the source.
#[("payment gateway unreachable")]
Gateway(#[] GatewayError),
}A caller that cares about the difference can match on it:
match place_order(req) {
Err(OrderError::NotFound(id)) => return not_found(id),
Err(OrderError::PaymentDeclined{..})=> return payment_required(),
Err(OrderError::Gateway(_)) => return retry_later(), // transient
Ok(order) => order,
}At the application top level, anyhow collapses everything and — crucially — attaches context at each propagation step so the chain reads like a narrative:
use anyhow::{Context, Result};
fn run_checkout(req: CheckoutRequest) -> Result<Receipt> {
let order = place_order(&req)
.context("failed to place order")?; // adds a 'why' layer
let receipt = charge(&order)
.with_context(|| format!("charging order {}", order.))?;
Ok(receipt)
}
// A failure now logs:
// charging order 8f3a: failed to place order: payment gateway unreachable: DNS timeoutAnd at the HTTP boundary, you translate types to status codes and keep internals internal:
impl IntoResponse for OrderError {
fn into_response(self) -> Response {
let status = match self {
OrderError::NotFound(_) => StatusCode::NOT_FOUND,
OrderError::PaymentDeclined{..}=> StatusCode::PAYMENT_REQUIRED,
OrderError::Gateway(_) => StatusCode::BAD_GATEWAY,
};
// Log the full chain server-side; send the client a clean message.
(status, self.to_string()).into_response()
}
}.unwrap() / .expect() in a library turns a recoverable error into a process crash the caller can’t intercept. Return a Result and let the caller decide. Save .expect() for genuinely-impossible invariants — and write the message as a proof of why it can’t happen.
The single decision that produced our “internal server error” opener was dropping #[source] / #[from] somewhere in the stack. Once the chain breaks, the why is gone forever — no amount of logging downstream recovers it. Preserve the source at every conversion.
# The DevOps Perspective
Good error architecture is an operability feature, and it shows up directly in your mean-time-to-diagnosis.
Structured logs, full chain. Render the entire error chain into structured logs (one field per layer, or the formatted chain), so the why is searchable. Enable backtraces where the overhead is acceptable (RUST_BACKTRACE=1; anyhow captures a backtrace when available) — a stack trace plus a context chain together tell you both where and why.
flowchart LR
F["Failure occurs"] --> C["Context added at each layer"]
C --> L["Structured log:<br/>full source chain"]
L --> D["MTTD: minutes, not an hour"]
F -.->|"chain dropped"| X["'internal server error'"]
X -.-> Y["MTTD: bisect by hand"]
classDef good fill:#059669,color:#fff,stroke:#065f46;
classDef bad fill:#dc2626,color:#fff,stroke:#991b1b;
classDef hub fill:#2563eb,color:#fff,stroke:#1e40af;
class F hub;
class C,L,D good;
class X,Y bad;Alert on variants, not strings. Because domain errors are typed, you can label metrics and alerts by variant — spike in Gateway errors pages the on-call for the payment dependency; a rise in PaymentDeclined is a business signal, not an outage. Stringly-typed errors make this impossible; you’re stuck grepping log text.
Don’t leak internals across the boundary. The rich chain is for your logs. The client gets a stable, sanitized message and a status code — leaking internal error detail (paths, SQL, upstream hostnames) is both a confusing contract and a security smell.
Pros
- Typed domain errors let callers branch and let you alert per-variant
- Context chains turn cryptic failures into readable narratives
- Clean HTTP contract: variants → status codes, internals stay internal
- Faster MTTD in incidents
- Both crates compose — libraries typed, app opaque
Cons
- Requires discipline: preserve #[source]/#[from] at every conversion
- Error enums are public API — variants are breaking changes
- Over-enumerating tiny apps adds ceremony for no benefit
- Backtraces add overhead
- leaking chains to clients is a security risk
# Putting It Together
“thiserror or anyhow” was never the real question. The real question is how errors cross the boundaries in your system: typed and matchable where callers must react, opaque and contextful where they only report, with the cause chain preserved the whole way up. Get the boundary right and error handling stops being a chore and becomes one of your best operability features — the difference between a forty-minute bisect and a log line that names the DNS timeout for you.
# References
- Error Handling In Rust — A Deep Dive — Luca Palmieri
- How to Design Error Types with thiserror and anyhow in Rust — OneUptime
- Error Handling in Rust: anyhow and thiserror — Caroline Morton
- How to Handle Errors with Result and Option in Rust — OneUptime
- Error Handling Best Practices in Rust — Syed Murtza
- Rust Error Handling: Result, Option, and the ? Operator Mastered — dasroot
Click to show appreciation
Discussion