SKILL.md
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 を広げない。
```rust use anyhow::Result;
fn main() -> Result<()> { app::run()?; Ok(()) } ```
.context()/.with_context()でエラーに文脈を必ず付ける
- 「どの操作中に失敗したのか」がわかるメッセージを付ける。
```rust use anyhow::{Context, Result};
fn loadconfig(path: &str) -> Result<String> { std::fs::readtostring(path) .withcontext(|| format!("failed to read config from {path}")) } ```
- 「ハンドルできない/ハンドルしない」境界でのみ anyhow に集約する
- HTTP レイヤや CLI レイヤで「ログを出す」「ユーザー向けメッセージに変換する」直前で、 下位の thiserror ベースのエラーを anyhow::Error に吸わせるのは OK。 - それより下の層では 独自 Error 型のまま 保つ。
unwrap/expectの禁止(初期化コードなど例外的ケースを除く)
- ランタイムで発生しうる失敗はすべて Result / Option として扱い、? と anyhow / thiserror で処理する。
ライブラリ/ドメイン層でのルール — thiserror
- Public API では
anyhowを返さず、自前の Error 型を定義する
- pub fn ... -> Result<T, Error> の Error は自前の enum / struct。 - anyhow::Error を public API に出すのは禁止。
```rust use 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] を使って自動変換する。 - これにより ? 演算子で自然に伝搬できる。
- エラー型は「使う側の判断に必要な粒度」で設計する
- 「ユーザー入力ミス」「外部サービスの障害」「内部バグ」など、 リトライ可否や 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 つの巨大な Error enum に何でも詰め込まず、 「RepositoryError」「DomainError」「ApiError」のように責務ごとに分割する。
thiserrorエラーのCloneは条件付きで採用する
- 上位レイヤで「最後のエラー保持」「再利用」「再送」などが必要で、 かつ内包するエラー型が Clone を実装済みなら #[derive(Clone, Debug, Error)] を採用する。 - Clone が不要なエラー型にはむやみに付けない。
Option<Error>の状態保持では参照アクセサを併設する
- Option<Error> をキャッシュする状態型では、 不要な clone を避けるため Option<&Error> を返す参照アクセサ(例: last_ref())を用意する。 - 文字列化やログ出力など「参照で足りる」処理は参照アクセサを使う。 - 既存 API 互換が必要な場合のみ、所有権を返すアクセサ(例: last())を併存させる。
チェックリスト
- ドメイン層の public API は
Result<T, DomainError>(または責務別Error)になっている -
#[from]による source 保持ができている(原因追跡できる) - アプリ境界で
.context()/.with_context()が付与されている -
unwrap/expectが残っていない(例外: テスト、明示された初期化のみ) - HTTP/CLI変換が match で明示され、判断基準が読み取れる
-
Cloneが必要なthiserrorエラーだけにClonederive を付与している -
Option<Error>の状態保持型で、参照アクセサ(例:last_ref())を優先して不要 clone を避けている