Rust Error Handling: anyhow / thiserror の境界設計
概要
目的
- •例外的な失敗を「握りつぶさず」「原因を辿れる形」で伝搬し、境界で適切に変換する。
- •ドメイン層のAPIを型付きエラーで安定させ、上位で集約・ログ化・ユーザー向け変換ができるようにする。
適用範囲
- •ライブラリ/ドメイン層:
thiserrorによる型付きエラー(Result<T, Error>) - •アプリケーション境界(main/CLI/HTTPハンドラ等):
anyhow::Resultと.context()/.with_context()
やらないこと
- •ドメイン層の public API に
anyhow::Errorを露出しない。 - •「とりあえず
Stringエラー」で返さない(判断不能になる)。
前提となる役割分担
- •
anyhow- •
anyhow::Errorとanyhow::Result<T>による「型消去された汎用エラー型」。 - •アプリケーションコードでの「簡易なエラー統合・伝搬・コンテキスト付与」に用いる。
- •
- •
thiserror- •
#[derive(Error)]でstd::error::Error実装を自動生成するためのクレート。 - •ライブラリ/ドメイン層での「型付きエラー定義」に用いる。
- •
- •
ライブラリ/ドメイン層 →
thiserrorで意味のある Error 型を定義 - •
アプリケーション境界(
mainなど) → 複数の Error をanyhowでまとめて扱う
アプリケーション層(binary crate)でのルール — anyhow
- •
戻り値は
anyhow::Result<T>を使うのは「最上位だけ」- •
mainや CLI ハンドラ、HTTP サーバのエントリポイントなど、
「最終的にログを出して終了/レスポンスに変換する層」に限定してanyhow::Result<()>を使う。 - •ドメインロジックにまで
anyhow::Resultを広げない。
rustuse anyhow::Result; fn main() -> Result<()> { app::run()?; Ok(()) } - •
- •
.context()/.with_context()でエラーに文脈を必ず付ける- •「どの操作中に失敗したのか」がわかるメッセージを付ける。
rustuse anyhow::{Context, Result}; fn load_config(path: &str) -> Result<String> { std::fs::read_to_string(path) .with_context(|| format!("failed to read config from {path}")) } - •
「ハンドルできない/ハンドルしない」境界でのみ anyhow に集約する
- •HTTP レイヤや CLI レイヤで「ログを出す」「ユーザー向けメッセージに変換する」直前で、
下位の
thiserrorベースのエラーをanyhow::Errorに吸わせるのは OK。 - •それより下の層では 独自 Error 型のまま 保つ。
- •HTTP レイヤや CLI レイヤで「ログを出す」「ユーザー向けメッセージに変換する」直前で、
下位の
- •
unwrap/expectの禁止(初期化コードなど例外的ケースを除く)- •ランタイムで発生しうる失敗はすべて
Result/Optionとして扱い、?とanyhow/thiserrorで処理する。
- •ランタイムで発生しうる失敗はすべて
ライブラリ/ドメイン層でのルール — thiserror
- •
Public API では
anyhowを返さず、自前の Error 型を定義する- •
pub fn ... -> Result<T, Error>のErrorは自前の enum / struct。 - •
anyhow::Errorを public API に出すのは禁止。
rustuse thiserror::Error; #[derive(Debug, Error)] pub enum RepositoryError { #[error("db error: {0}")] Db(#[from] sqlx::Error), #[error("entity not found: {id}")] NotFound { id: String }, } pub type Result<T> = std::result::Result<T, RepositoryError>; - •
- •
#[from]で外部エラーをラップし、source を保持する- •依存クレートのエラーや IO エラーは、
#[from]を使って自動変換する。 - •これにより
?演算子で自然に伝搬できる。
- •依存クレートのエラーや IO エラーは、
- •
エラー型は「使う側の判断に必要な粒度」で設計する
- •「ユーザー入力ミス」「外部サービスの障害」「内部バグ」など、 リトライ可否や HTTP ステータス変換などに必要な分類を enum variant として持たせる。
rust#[derive(Debug, Error)] pub enum DomainError { #[error("invalid input: {0}")] InvalidInput(String), #[error("external service failed: {0}")] External(String), #[error("unexpected internal error")] Internal(#[from] anyhow::Error), // ← ドメイン内だけで包むのはアリ } - •
Error 型はモジュール/境界ごとに分ける
- •1 つの巨大な
Errorenum に何でも詰め込まず、 「RepositoryError」「DomainError」「ApiError」のように責務ごとに分割する。
- •1 つの巨大な
チェックリスト
- • ドメイン層の public API は
Result<T, DomainError>(または責務別Error)になっている - •
#[from]による source 保持ができている(原因追跡できる) - • アプリ境界で
.context()/.with_context()が付与されている - •
unwrap/expectが残っていない(例外: テスト、明示された初期化のみ) - • HTTP/CLI変換が match で明示され、判断基準が読み取れる