Error Design

Goals

  • Make error types express domain boundaries and caller responsibility.
  • Control error visibility to avoid leaking internal details.
  • Preserve diagnostic context for debugging and tracing.

Guidance

  • Define error semantics and layers before choosing where to use ?.
  • Map errors at boundaries instead of forwarding everything upward.
  • Treat public error enums as contracts; avoid frequent breaking changes.
  • Design stable tests and assertions for critical failure paths.
  • Design for two audiences: machines need flat, actionable kinds; humans need rich context.
  • Prefer error kinds based on caller action (retry, not found, invalid input) over dependency origin.
  • Make context capture low-friction and consistent at module boundaries.

Anti-Patterns

Origin-based enums that mirror dependencies and forward without context:

#[derive(Debug, thiserror::Error)]
pub enum ServiceError {
    #[error("db error: {0}")]
    Db(#[from] sqlx::Error),
    #[error("http error: {0}")]
    Http(#[from] reqwest::Error),
}

pub fn handle(req: Request) -> Result<Response, ServiceError> {
    let user = db_get(req.user_id)?;
    let data = fetch_api(user.api_key)?;
    Ok(render(data)?)
}

Positive Patterns

Actionable error kinds plus low-friction context at boundaries:

#[derive(Debug, Clone, Copy)]
pub enum ErrorKind {
    NotFound,
    RateLimited,
    InvalidInput,
    Temporary,
}

#[derive(Debug)]
pub struct AppError {
    kind: ErrorKind,
    message: String,
}

impl AppError {
    pub fn new(kind: ErrorKind, message: impl Into<String>) -> Self {
        Self { kind, message: message.into() }
    }
}

pub fn fetch_user(id: &str) -> Result<User, AppError> {
    let raw = call_upstream(id)
        .map_err(|_| AppError::new(ErrorKind::Temporary, format!("fetch_user {id}")))?;
    parse_user(&raw)
        .map_err(|_| AppError::new(ErrorKind::NotFound, format!("user {id}")))
}

Sources & References