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 Programms — main.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:
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:
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.
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())
}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
| Situation | Empfehlung |
|---|---|
| Library-Crate auf crates.io | thiserror |
| Internes Library-Modul (lib.rs) | thiserror |
| CLI-Tool / Binary | anyhow |
| Web-Server / Backend | anyhow (oft mit thiserror für interne Library-Schichten) |
| Lange-Lebende Service-Application | anyhow (Top-Level) + thiserror (Domain-Errors) |
| Sehr kleines Projekt (1 Error-Quelle) | direkt der Quell-Typ als Error |
| no_std / Embedded | hand-implementiert oder thiserror (in no_std-Modus) |
| Skript / Prototyp | Box<dyn Error> oder anyhow |
| Test-Code | anyhow 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
// 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
// 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
# 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
// 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
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
// 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
// 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
// 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
// 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
- Error Handling Project Group – Roadmap
- thiserror auf crates.io
- anyhow auf crates.io
- Rust API Guidelines – Errors
- The Rust Book – Error Handling