Nachdem wir alle Bausteine kennengelernt haben — panic, Result, ?, eigene Error-Typen, thiserror, anyhow —, kommt die wichtigste Frage: wann nutze ich was? Die Antwort hängt von der Rolle deines Codes ab: schreibst du eine Library (Code, der von anderen konsumiert wird) oder ein Binary (Top-Level-Programm)? Beide Welten haben unterschiedliche Anforderungen — und unterschiedliche Best-Practices. Dieser Artikel führt durch die strategischen Designentscheidungen und gibt konkrete Faustregeln für die alltägliche Arbeit.

Library vs. Binary — die Schlüssel-Unterscheidung

Die wichtigste Frage beim Error-Strategie-Design: wer konsumiert die Fehler?

Library-Code ist Code, der von anderen Programmen konsumiert wird — sei es ein Crate auf crates.io, ein internes Shared-Module, oder einfach eine Crate-interne Bibliotheks-Schicht, die mehrere Binaries unterstützt. Die Konsumenten sind andere Entwickler, die den Code in ihren eigenen Programmen verwenden. Sie wollen Fehler programmatisch behandeln — auf bestimmte Varianten unterschiedlich reagieren, sie loggen, weiterreichen oder transformieren.

Binary-Code ist Code an der Spitze des Programmsmain.rs, CLI-Tools, Server-Endpoints, Skripte. Der eigentliche „Konsument" der Fehler ist hier oft der Mensch, der das Programm ausführt. Er will eine sprechende Fehler-Nachricht, einen Stack-Trace im Debug-Modus, vielleicht einen Exit-Code für CI-Integration. Programmatische Differenzierung verschiedener Fehler-Klassen ist selten wichtig — der Mensch interpretiert.

Aus dieser Unterscheidung folgt die Crate-Wahl:

  • Library → strukturierte Errors mit thiserror (oder Hand-implementiert).
  • Binary → dynamische Errors mit anyhow.

Diese Empfehlung ist nicht beliebig — sie folgt direkt aus den Anforderungen der jeweiligen Konsumenten.

Library-Strategie: strukturiert mit thiserror

Für Library-Code ist thiserror die idiomatische Wahl. Konkrete Empfehlungen:

Rust Library-Pattern
use thiserror::Error;
use std::io;

// Crate-Level-Error
#[derive(Debug, Error)]
pub enum DbError {
    #[error("Verbindungs-Fehler: {0}")]
    Connection(#[from] io::Error),

    #[error("Query-Fehler: {0}")]
    Query(String),

    #[error("Schema-Mismatch: erwartet {expected}, gefunden {found}")]
    SchemaMismatch { expected: String, found: String },
}

// Library-spezifischer Result-Alias
pub type Result<T> = std::result::Result<T, DbError>;

// Public API nutzt den Alias
pub fn connect(url: &str) -> Result<Connection> {
    // ...
    # let _ = url; Ok(Connection)
}
# pub struct Connection;

Zentrale Bausteine:

Ein Error-Enum pro Library/Modul-Bereich mit Varianten für die wichtigen Fehler-Klassen. Konsumenten können per match auf bestimmte Varianten reagieren — etwa „bei SchemaMismatch erstelle das neue Schema und retry, bei anderen Fehlern brich ab".

Ein Result-Type-Alias auf Crate-Ebene. Macht Funktions-Signaturen kompakter und folgt dem Stdlib-Pattern (io::Result<T>).

thiserror für die Boilerplate-Reduktion. Manuell wäre dasselbe Möglich, aber thiserror spart 80 % der Schreibarbeit.

Was vermeiden

In Library-Code solltest du anyhow nicht als Error-Typ exponieren. Das nimmt Konsumenten die Möglichkeit, programmatisch zu reagieren — sie sehen nur einen anyhow::Error und können nicht typ-spezifisch matchen. Ein Library-API mit anyhow::Result<T> als öffentlicher Rückgabe ist ein Anti-Pattern.

Intern kannst du anyhow durchaus nutzen — etwa für komplexe Verarbeitungs-Schritte, die viele verschiedene Sub-Errors haben. An der API-Grenze konvertierst du dann in deinen strukturierten Library-Error.

Binary-Strategie: dynamisch mit anyhow

Für Top-Level-Code ist anyhow die idiomatische Wahl. Konkrete Empfehlungen:

Rust Binary-Pattern
use anyhow::{Context, Result};

fn lade_config(pfad: &str) -> Result<String> {
    std::fs::read_to_string(pfad)
        .with_context(|| format!("Beim Lesen der Config '{pfad}'"))
}

fn parse_workers(s: &str) -> Result<u32> {
    s.parse().with_context(|| format!("workers-Wert '{s}' ungültig"))
}

fn main() -> Result<()> {
    let config = lade_config("/etc/myapp.conf")?;
    let workers: u32 = parse_workers(config.trim())?;
    println!("Starte mit {workers} Workern");
    Ok(())
}

Zentrale Bausteine:

fn main() -> anyhow::Result<()> als Standard-Signatur. Der ?-Operator funktioniert überall, Fehler werden mit Debug-Output ausgegeben, Exit-Code ist 1 bei Fehler.

.context() und .with_context() an jedem fallible-Aufruf zur Anreicherung mit Diagnose-Information. Das macht den finalen Fehler-Output für den End-User sprechend.

Keine eigenen Error-Enums — anyhow ist universell, du brauchst keine eigene Typ-Hierarchie. Bei sehr spezifischen Domain-Errors kannst du intern einen kleinen thiserror-Enum bauen und ihn in anyhow konvertieren.

Was vermeiden

In Binary-Code solltest du keine kompletten Error-Enums für jeden möglichen Fehler bauen. Das ist Aufwand ohne Nutzen — der Konsument (Mensch) differenziert sowieso nicht programmatisch. Anyhow plus Context reicht praktisch immer.

Eine Ausnahme: wenn dein Binary einen eigenen Exit-Code-Mapping braucht (etwa für CI-Integration), kann ein kleiner Error-Enum mit exit_code()-Methode sinnvoll sein. Aber das ist die Ausnahme.

Die Kombi: Library + Binary

In einem typischen Crate-Workspace gibt es beide Welten: eine lib.rs (Library-Code) und ein main.rs (Binary-Code). Jede nutzt ihr passendes Pattern.

Rust lib.rs - Library
use thiserror::Error;
use std::io;

#[derive(Debug, Error)]
pub enum LibError {
    #[error("Datenbank: {0}")]
    Database(String),
    #[error("Auth: {0}")]
    Auth(String),
    #[error("I/O: {0}")]
    Io(#[from] io::Error),
}

pub type Result<T> = std::result::Result<T, LibError>;

pub fn lade_user(id: u64) -> Result<String> {
    // ... Logik mit ? auf io-Operationen ...
    # let _ = id; Ok(String::new())
}
Rust main.rs - Binary
use anyhow::{Context, Result};
# use std::result::Result as StdResult;
# #[derive(Debug)] pub enum LibError {}
# impl std::fmt::Display for LibError {
#     fn fmt(&self, _: &mut std::fmt::Formatter) -> std::fmt::Result { Ok(()) }
# }
# impl std::error::Error for LibError {}
# mod my_lib {
#     use super::*;
#     pub fn lade_user(id: u64) -> StdResult<String, LibError> { let _ = id; Ok(String::new()) }
# }

fn main() -> Result<()> {
    let args: Vec<String> = std::env::args().collect();
    let id_str = args.get(1).context("Bitte User-ID angeben")?;
    let id: u64 = id_str.parse().context("ID muss eine Zahl sein")?;

    // LibError → anyhow::Error via Blanket-Impl (Error-Trait)
    let user = my_lib::lade_user(id)
        .with_context(|| format!("Laden von User {id}"))?;

    println!("{user}");
    Ok(())
}

Im lib.rs ist der Error strukturiert (LibError-Enum). Im main.rs ist alles dynamisch (anyhow::Result). Die Konvertierung passiert automatisch via ?: LibError implementiert std::error::Error, also greift die Blanket-Impl From<E: Error> for anyhow::Error.

Mit .with_context() reichert das Binary den Library-Fehler mit Kontext an. Im Output erscheint dann: „Laden von User 42" → „Datenbank: Connection refused".

Diese Aufteilung ist die idiomatische Rust-Architektur für Crates mit gemischten Komponenten.

Wann reicht die Stdlib?

Beide Crates sind sehr leichtgewichtig — fast jedes Rust-Projekt sollte sie nutzen können. Aber es gibt Situationen, in denen pure Stdlib reicht:

Sehr kleine Projekte mit nur einer Fehler-Quelle. Wenn dein Code nur io::Error produziert, brauchst du keinen eigenen Error-Typ — du gibst direkt Result<T, io::Error> zurück.

no_std-Code (etwa Embedded). thiserror funktioniert in no_std, anyhow nicht. Für Embedded ist oft eine reine Stdlib-Lösung die einzige Option.

Streng minimalistische Crates mit Fokus auf wenige Dependencies. Bei einem Crate, das mit minimal-möglichem Dependency-Footprint auskommen soll, kann die Hand-implementierte Error-Variante sinnvoller als thiserror sein.

Kurze Skripte / Prototypen. Für Code, der nicht in Production geht, reicht oft Result<T, Box<dyn std::error::Error>> ohne extra Crate.

In allen anderen Fällen sind thiserror/anyhow die richtige Wahl — sie machen die Boilerplate trivial und ihre Costs sind null Runtime, minimaler Compile-Time-Impact.

Entscheidungs-Tabelle

SituationEmpfehlung
Library-Crate auf crates.iothiserror
Internes Library-Modul (lib.rs)thiserror
CLI-Tool / Binaryanyhow
Web-Server / Backendanyhow (oft mit thiserror für interne Library-Schichten)
Lange-Lebende Service-Applicationanyhow (Top-Level) + thiserror (Domain-Errors)
Sehr kleines Projekt (1 Error-Quelle)direkt der Quell-Typ als Error
no_std / Embeddedhand-implementiert oder thiserror (in no_std-Modus)
Skript / PrototypBox<dyn Error> oder anyhow
Test-Codeanyhow oder Box<dyn Error>
Async Server (tokio)anyhow + thiserror, mit Send + Sync

Diese Empfehlungen sind nicht in Stein gemeißelt — aber sie repräsentieren die Mehrheits-Praxis in der Rust-Community. Wer von ihnen abweicht, sollte gute Gründe haben.

Konkrete Patterns

Layered Error Architecture

Rust Mehrstufige Errors
// Lowest layer: domain-spezifischer Library-Error
use thiserror::Error;

#[derive(Debug, Error)]
pub enum DbError {
    #[error("Connection: {0}")]
    Connection(String),
    #[error("Query: {0}")]
    Query(String),
}

// Middle layer: Service-Logik
#[derive(Debug, Error)]
pub enum UserServiceError {
    #[error("Datenbank: {0}")]
    Db(#[from] DbError),
    #[error("User {id} nicht gefunden")]
    NotFound { id: u64 },
    #[error("Berechtigung verweigert")]
    Forbidden,
}

// Top layer: Application-Code mit anyhow
// use anyhow::Result;
// pub async fn http_get_user(req: Request) -> Result<Response> {
//     let id = parse_id(&req)?;
//     let user = user_service.lade(id).await
//         .with_context(|| format!("HTTP GET /users/{id}"))?;
//     Ok(Response::json(user))
// }

In komplexen Anwendungen entsteht eine Layered-Architecture mit verschiedenen Error-Typen pro Layer. Der unterste hat sehr spezifische Errors (DbError), der mittlere wickelt sie und ergänzt Domain-Logik (UserServiceError), der oberste arbeitet mit anyhow für Context-Anreicherung.

Error-Conversion an der API-Grenze

Rust Conversion am Boundary
// Interne Logik nutzt anyhow für Komfort
async fn process_internal(data: &[u8]) -> anyhow::Result<String> {
    // ... viele verschiedene Sub-Errors via ? ...
    # let _ = data; Ok(String::new())
}

// Public-API exposed eigenen Error-Typ
use thiserror::Error;

#[derive(Debug, Error)]
pub enum PublicError {
    #[error("Internal: {0}")]
    Internal(String),
}

pub async fn process(data: &[u8]) -> Result<String, PublicError> {
    process_internal(data).await.map_err(|e| {
        PublicError::Internal(format!("{e:?}"))
    })
}

Intern nutzt der Code anyhow für sein Komfort. An der API-Grenze zur Außenwelt wird in einen strukturierten Error konvertiert. Damit haben Library-Konsumenten den klaren Vertrag (PublicError), während die interne Logik vom anyhow-Komfort profitiert.

Test-Code mit anyhow

Rust Tests
# use anyhow::Result;
# fn setup_test_db() -> Result<()> { Ok(()) }
# fn execute_query(_: &str) -> Result<Vec<String>> { Ok(vec![]) }

#[test]
fn user_query_funktioniert() -> Result<()> {
    setup_test_db()?;
    let users = execute_query("SELECT * FROM users")?;
    assert!(!users.is_empty());
    Ok(())
}

In Tests ist anyhow oft die richtige Wahl — die Test-Funktion gibt anyhow::Result<()> zurück, ? funktioniert überall, ohne Boilerplate. Bei einem Test-Fehler siehst du die volle Error-Chain im Test-Output.

Error mit Backtraces in Production

Rust Backtraces
// In main.rs:
fn main() -> anyhow::Result<()> {
    // Stelle sicher, dass Backtraces auch in Release ausgegeben werden:
    std::env::set_var("RUST_BACKTRACE", "1");

    // ... Programm-Logik ...
    Ok(())
}

Für Production-Logs ist RUST_BACKTRACE=1 essentiell — der Stack-Trace im Fehler-Output verrät dir oft sofort die Stelle, an der das Problem entstand. Manche Crates lesen die Variable automatisch beim Start; explizites Setzen ist eine Defensive-Maßnahme.

Custom-Error mit JSON-Serialization

Rust API-Errors
use thiserror::Error;
// use serde::Serialize;

#[derive(Debug, Error)]
// #[derive(Debug, Error, Serialize)]
pub enum ApiError {
    #[error("Resource not found")]
    NotFound,
    #[error("Permission denied")]
    Forbidden,
    #[error("Internal error: {0}")]
    Internal(String),
}

// impl ApiError {
//     pub fn http_status(&self) -> u16 {
//         match self {
//             ApiError::NotFound => 404,
//             ApiError::Forbidden => 403,
//             ApiError::Internal(_) => 500,
//         }
//     }
//
//     pub fn to_json(&self) -> serde_json::Value {
//         serde_json::json!({
//             "error": self.to_string(),
//             "status": self.http_status(),
//         })
//     }
// }

Für HTTP-APIs ist ein strukturierter Error-Enum mit HTTP-Status-Mapping und JSON-Serialization sehr typisch. thiserror plus serde::Serialize plus eigene Methoden ergeben ein vollständiges API-Error-System.

Anti-Patterns

Was du vermeiden solltest:

String als Error-Typ

Rust Anti-Pattern: String
// SCHLECHT — String als Error
fn lade() -> Result<String, String> {
    std::fs::read_to_string("foo").map_err(|e| e.to_string())
}

String verliert alle Typ-Information. Konsumenten können nicht programmatisch reagieren, Error-Chains gehen verloren, Debug-Output ist nicht informativ. Wenn du in einem Prototyp etwas Schnelles brauchst: nutze Box<dyn Error> oder anyhow::Error statt String.

Panic für erwartete Fehler

Rust Anti-Pattern: panic
// SCHLECHT — panic für erwartbaren Fehler
fn lade_config(pfad: &str) -> String {
    std::fs::read_to_string(pfad).unwrap()    // panic bei fehlender Datei
}

Eine fehlende Konfig-Datei ist kein Bug — es ist ein erwartbarer Zustand. unwrap panickt darüber und macht den Aufrufer hilflos. Korrekte Variante: Result zurückgeben.

anyhow in Library-API

Rust Anti-Pattern: anyhow in Library
// SCHLECHT — anyhow in einer Library-API
// pub fn lade_daten() -> anyhow::Result<Vec<u8>> {
//     // ...
// }

Konsumenten der Library bekommen einen Type-Erased Error und können nicht differenzieren. Library-APIs sollen strukturierte Errors exponieren. Intern darfst du anyhow nutzen — aber an der API-Grenze in einen konkreten Typ konvertieren.

Unnötige Wrapper-Errors

Rust Anti-Pattern: Wrapper ohne Mehrwert
// FRAGWÜRDIG — eigener Error nur für io-Wrapping
// pub enum MyError {
//     IoError(io::Error),
// }

Ein Error-Enum mit nur einer Variante, die einen Stdlib-Fehler wickelt, bringt keinen Mehrwert. Gib einfach direkt io::Error zurück. Eigene Error-Typen lohnen sich, sobald du mehrere Quellen, Domain-Validierungs-Errors oder programmatische Differenzierung willst.

Besonderheiten

Library = thiserror, Binary = anyhow.

Die zentrale Faustregel. Library-Konsumenten wollen strukturiert reagieren — thiserror gibt ihnen das. Binary-Konsumenten (Menschen) wollen kontextreiche Diagnose — anyhow liefert das.

Beide ergänzen sich, sind keine Alternativen.

In einem typischen Workspace mit lib.rs + main.rs nutzt du beide. Library exposed thiserror-Errors, Binary wickelt sie in anyhow für End-User-Diagnose.

Niemals String als Error-Typ.

Verliert Typ-Information, kein source-Chain, schlechte Debug-Output. Für quick-and-dirty: Box<dyn Error> oder anyhow::Error. Für strukturiert: eigener Enum mit thiserror.

In Library-APIs kein anyhow exposen.

Konsumenten brauchen Typ-Information für programmatische Behandlung. anyhow ist intern nutzbar, aber an der API-Grenze in strukturierten Typ konvertieren.

Result-Type-Alias spart Tipparbeit.

pub type Result<T> = std::result::Result<T, MyError>; in der Library, oder pub type Result<T> = anyhow::Result<T>; im Binary. Signatur-Zeilen werden kompakter.

Layered Architecture für komplexe Apps.

Lowest Layer: domain-spezifische thiserror-Errors. Middle Layer: Service-Errors, die untere wrappen. Top Layer: anyhow mit Context-Anreicherung. Saubere Trennung der Verantwortlichkeiten.

RUST_BACKTRACE=1 für Production-Logs.

Die Umgebungs-Variable aktiviert Stack-Traces in Fehler-Output. Essentiell beim Debuggen von Production-Issues. Manche Code-Stellen setzen die Variable explizit beim Start.

In Tests ist anyhow oft praktisch.

Test-Funktionen mit -> anyhow::Result<()> plus ? machen Test-Code sehr kompakt. Bei Fehler siehst du die volle Chain. Strukturierter Error wäre Overkill für Test-Setup-Code.

Weiterführende Ressourcen

Externe Quellen

/ Weiter

Zurück zu Error Handling

Zur Übersicht