本記事について
当サイトを閲覧いただきありがとうございます。 本記事はシリーズ『生成AI時代のアーキテクチャ超入門』の「アプリケーションアーキテクチャ」カテゴリ最終記事(第4弾)として、エラーハンドリングについて解説する記事です。
正常系はどの実装でも似通いますが、違いが出るのは「DBが一瞬落ちた」「外部APIが遅延した」「入力が想定外だった」時の振る舞い。ここでの設計が信頼性とUXを決めます。本記事ではエラー分類・例外vsResult型・エラー境界・相関ID・リトライ戦略・冪等性・Circuit Breaker・タイムアウト・Bulkheadなど、「落ちても復旧できるシステム」の設計指針を示します。
このカテゴリの他の記事
ソフトウェアの品質は「異常系」に出る
エラー設計が甘いと、些細な障害が連鎖してシステム全体を巻き込みます。1つのマイクロサービスが遅延しただけで、呼び出し元のスレッドが全部詰まり、そこを呼び出していた別サービスも詰まり、と雪崩式に全体が止まる。こうした事故を防ぐには「エラーはどこで発生し、どこで捕まえ、どう伝え、どう回復するか」を設計段階で決める必要があります。
キャリアの初期に先輩から「正常系の10倍の設計工数を異常系にかけろ」と言われて最初は信じなかったが、運用を経験するうちに本当に正しいと思い知らされた、という体験談はよく聞きます。運用事故の9割は異常系の設計不備です。
「落ちないシステム」ではなく「落ちても復旧できるシステム」を作る。それがエラー設計の本質です。
エラーの分類
エラー設計で最初にやるべきは、「発生するエラーを性質ごとに分類する」ことです。プログラムバグ・入力エラー・業務エラー・一時障害・恒常障害、これらは原因も適切な対処法も全く違うのに、多くのコードベースでは一つの Error や Exception にまとめられ、同じ catch で処理されがちです。これは後から見ると致命的な罠になります。
分類を怠ると具体的に3つの事故が起きます。第一に、ユーザーに見せてはいけない内部エラー(スタックトレースやDB障害詳細)が画面に露出する事故。第二に、リトライすべき一時障害と、リトライしてはいけない業務エラー(残高不足など)が区別できず、二重決済のような重大事故を生みます。第三に、バグと想定内の業務エラーが混ざると、障害アラートが常時鳴り続けて「重要な警告が埋もれる」分類は全てのエラー戦略の出発点です。
| 種類 | 例 | 対応 |
|---|---|---|
| プログラムバグ | NullPointer・型エラー | 修正するしかない |
| 入力エラー | バリデーション失敗 | ユーザーに返却して再入力を促す |
| 業務エラー | 在庫不足・残高不足 | 業務フローで処理する |
| 一時障害 | ネットワーク・外部APIタイムアウト | リトライで回復を試みる |
| 恒常障害 | 認証失敗・権限不足 | リトライ不可・即座に失敗させる |
エラーの種類ごとに扱いが違います。「共通の基底クラスで一括catchする設計」は最悪です。
例外 vs Result型
エラーをコードで表現する方法は、大きく例外方式とResult型方式の2つで、言語によって標準が異なります。「例外の暗黙性」を嫌ってResult型の人気が上がっていますが、どちらが絶対に正しいわけではありません。
| 方式 | 代表言語 | 特徴 |
|---|---|---|
| 例外(throw) | Java / C# / Python / JS | 暗黙の制御フロー・通常は書かなくて済む |
| Result型 | Rust / Go / Elm / Haskell | 明示的・型システムで扱いを強制 |
// Go の Result 風
value, err := repo.Find(id)
if err != nil { return err }
// Rust の Result
let value = repo.find(id)?; // ?演算子でエラーを上に伝搬
Goの if err != nil の冗長さや、Rustの ? の簡潔さなど、書き味は言語によって大きく異なります。言語の流儀に逆らって例外スタイルをResult的に書く、あるいはその逆は余計な複雑さを生むだけです。
どちらを選ぶか
例外とResult型は対立する概念ではなく、エラーの性質で使い分けるのが現代の主流です。両者を使い分けると、可読性と安全性が両立します。
| シーン | 推奨 | 理由 |
|---|---|---|
| 予期できる失敗(入力エラー・業務エラー) | Result / Either | 呼出側で必ず処理させたい |
| 予期できない失敗(バグ・DB障害・OOM(Out Of Memory、メモリ不足)) | 例外 | 各層で処理するより上位に投げる方が安全 |
全てを例外にすると、どの関数がどんなエラーを返すかが見えなくなり、catch漏れでアプリが落ちます。逆に全てをResult型にすると、ロジックが if err != nil だらけになって本質が埋もれます。「予期できる/できない」で線を引くのがバランスの良い設計です。
エラーの境界
エラーは「発生した場所で処理する」のではなく、「適切な境界で集約して処理する」のが原則です。各層には役割があり、技術的な例外が業務層やUI層にそのまま漏れないようにします。
flowchart BT
INFRA["Infrastructure層<br/>技術例外を発生<br/>(DB/Network)"]
DOMAIN["Domain層<br/>業務例外を投げる<br/>(業務ルール違反)"]
APP["Application層<br/>業務例外を定義<br/>技術例外は素通し"]
UI["UI / Controller層<br/>グローバルハンドラで集約<br/>HTTPステータスに変換"]
USER([ユーザー応答])
INFRA -->|throw| DOMAIN
DOMAIN -->|throw| APP
APP -->|throw| UI
UI --> USER
classDef infra fill:#fee2e2,stroke:#dc2626;
classDef domain fill:#fef3c7,stroke:#d97706;
classDef app fill:#dbeafe,stroke:#2563eb;
classDef ui fill:#fae8ff,stroke:#a21caf;
classDef user fill:#dcfce7,stroke:#16a34a;
class INFRA infra;
class DOMAIN domain;
class APP app;
class UI ui;
class USER user;
最上位の境界(コントローラやAPIゲートウェイ)で「まとめて捕捉」し、HTTPステータスやJSONレスポンスに変換します。各階層で毎回try/catchすると、コードがノイズまみれになる上、握り潰しのリスクも高まります。
「一箇所で全部捕まえる」グローバルエラーハンドラを持つのが現代の本命です。
アンチパターン
エラーハンドリングの失敗パターンには、コードレビューで毎回のように登場する定番があります。どれも「とりあえず動く」状態を優先した結果、後から重大な障害を生みます。
❌ catch (e) { /* 何もしない */ } ← 例外握り潰し(最悪)
❌ catch (Exception e) { log(e) } ← 全部同じ扱い(区別なし)
❌ throw new Error("error") ← 情報ゼロの例外
❌ return null / -1 で失敗を表現 ← 呼出側が気付かない
❌ try/catch の深いネスト ← 制御不能・読めない
特に「例外をcatchしてログだけ出して素通し」は最悪で、本番では障害として検知されないのに実データは壊れている、つまり「サイレント障害」を生みます。捕まえる時は具体的な型で、何ができるかを明確にしてから処理します。
例外の型を階層化(業務例外 / 技術例外 / バグ)し、catchは具体的な型で行うのが鉄則です。
「タイムアウトなし」が生んだ雪崩(業界事例)
2020年11月、AWSの大規模障害(us-east-1 Kinesisの停止、影響時間約17時間)は、まさに「スレッド枯渇」が起点の雪崩として語り継がれている事例です。CloudWatch・Cognito・SQS など多数のAWSサービスが連鎖的に影響を受け、教科書的な「外部依存の遅延連鎖」が現実規模で発生しました。
この手の事故は企業内でも日常茶飯事で、「外部APIを呼ぶHTTPクライアントにタイムアウトを設定し忘れたまま何年も動いていた」という話はあちこちで聞かれます。新人時代に外部APIのタイムアウト無指定のコードを本番で運用していて、ある月曜の朝に相手側のメンテ作業が長引いた瞬間、こちらのAPIサーバー全体が応答しなくなった、という体験談もよくあります。
普段は数十ミリ秒で返ってくるから問題になっていなかっただけで、ある日相手が詰まった瞬間、呼び出し元のスレッドが永遠に掴まれ続け、やがてプロセス全体が応答不能になる、という顛末です。Bulkhead・Circuit Breaker・タイムアウトは「起きてから足す」では絶対に間に合わない、というのが共通の教訓として残っています。
外部呼び出しにタイムアウトなしは、時限爆弾を置いているのと同じです。
ユーザー向けメッセージ
エラーメッセージには「開発者向け」と「エンドユーザー向け」の2つがあり、目的も内容も全く違います。これを混同すると、ユーザーに内部情報が漏れるか、開発者がデバッグできないかのどちらかに陥ります。
| 対象 | 方針 |
|---|---|
| エンドユーザー | 平易な文・具体的な回避策・個人情報や内部情報は含まない |
| 開発者 | スタックトレース・相関ID・入力値・タイムスタンプ |
❌ ユーザーに表示: "java.sql.SQLIntegrityConstraintException: duplicate key 'email'..."
✅ ユーザーに表示: "このメールアドレスは既に登録されています"
ログに記録: 詳細スタックトレース + trace_id: abc123 + user_id: 42
ユーザーに技術的詳細(スタックトレースやSQLエラー)をそのまま見せると、攻撃の手がかりになります。一方で「エラーが発生しました」だけ出して終わりだと、問い合わせが来た時に何が起きたか分かりません。「両者を同じ相関IDで紐付ける」のが定石です。
相関ID(Correlation ID)
マイクロサービス環境では、1つのリクエストが複数サービスを経由するため、どこで何が起きたかを追うのが困難になります。これを解決するのが相関ID(Correlation ID / Trace ID)で、リクエスト入口で付与した一意のIDを全サービスに伝播させます。
[Request X-Request-Id: abc123]
↓
[Service A] → log: trace=abc123 "処理開始"
↓
[Service B] → log: trace=abc123 "DB書き込み"
↓
[Service C] → log: trace=abc123 "通知送信失敗"
ユーザーに表示するエラー画面にもこのIDを載せれば、問い合わせ時に「エラー番号abc123でした」と伝えてもらうだけで、ログ横断で即座に経路と原因を追えます。現場の本命は OpenTelemetry で、相関IDの発行・伝播・可視化まで自動化できます。
マイクロサービスでは相関IDは必須です。後付けは辛いので最初から入れます。
リトライ戦略
一時障害(ネットワーク瞬断・外部APIの一時的なエラー等)は、数秒後にリトライすれば成功するケースがほとんどです。ただし無邪気にリトライするとさらに事態を悪化させるため、「バックオフ」と「Jitter」を組み合わせた戦略が鉄板です。
| 方式 | 内容 |
|---|---|
| 固定間隔 | 1秒ごとにN回リトライ(単純だが集中しやすい) |
| 指数バックオフ | 1→2→4→8秒と間隔を倍々で広げる |
| Jitter付き | バックオフにランダムな揺らぎを加える |
| 最大試行回数 | 3〜5回で諦める(無限ループ防止) |
多数のクライアントが同じ瞬間にリトライすると「Thundering Herd」(総攻撃)となり、復旧しかけた外部サービスを再び落とします。Jitterでタイミングをばらつかせるのが分散システムの鉄則です。AWS SDK・Google Cloud SDK など主要ライブラリは、デフォルトで Jitter 付き指数バックオフを実装しています。
「リトライ + Jitter + 最大試行回数」の三点セットを徹底するのが定石です。
冪等性(Idempotency)
リトライと必ずセットで検討すべきが冪等性です。冪等とは「同じリクエストを何度送っても結果が同じ」という性質で、これが保証されていないとリトライが二重決済・二重登録・二重発送といった事故を生みます。
❌ POST /users をネットワーク障害で3回リトライ
→ 同じユーザーが3人作られる
✅ POST /users + Idempotency-Key: uuid-abc123
→ 同じキーで2回目以降は最初の結果を返す
実装パターンは以下の通りです。
- クライアントが発行するUUID(Idempotency-Key)をリクエストに含める
- サーバーでそのキーを記録(DBの unique制約 / Redis にTTL付きで保存)
- 同じキーのリクエストが来たら、初回の結果をそのまま返す
Stripe API など決済系APIは、このIdempotency-Key方式を標準サポートしています。自前APIでも、「金銭や副作用を伴う処理には必ず」導入すべきパターンです。
Circuit Breaker
外部サービスが障害中の時、無駄な呼び出しを送り続けると自分も巻き込まれて倒れることがあります。これを防ぐのが Circuit Breaker(回路遮断器)パターンで、電気のブレーカーと同じ発想で動きます。
| 状態 | 動作 |
|---|---|
| Closed | 通常動作。全リクエストを通す |
| Open | 失敗が閾値を超えたら遮断。即座にエラーを返す |
| Half-Open | 一定時間後に試験リクエストを1本だけ通し、復旧判定 |
外部サービスが落ちてから「5秒でタイムアウトする呼び出しを毎秒1000回」続けると、自分のサーバーのスレッドプールが枯渇してダウンします。Circuit BreakerでOpen状態に移せば、失敗を即座に返して自分は生き延び、本体が復旧したら自動で再開できます。
実装は Resilience4j / Polly / Istio / Linkerd が代表例。外部依存のある本番サービスでは必須です。
タイムアウトとBulkhead
Circuit Breakerと並んで、外部依存を持つシステム全般で必須となる防御パターンが以下の3つです。これらは「障害を遮断する」のではなく、「障害の影響範囲を限定する」のが目的です。
| パターン | 役割 |
|---|---|
| Timeout | 無限待機を防ぐ。全ての外部呼び出しに必ず設定する |
| Bulkhead | リソース(スレッドプール等)を分離し、ブロッキング連鎖を防ぐ |
| Rate Limit | 1秒あたりN回までに制限し、過負荷から守る |
「タイムアウト未設定」は最も頻繁に見る事故原因です。HTTPクライアントやDB接続を無指定のまま使うと、相手が応答しない時にスレッドが永遠に掴まれ、遅延がやがてシステム停止へ発展します。Bulkhead は船の隔壁と同じで、1つの外部サービスの遅延が全スレッドを食い尽くさないよう、接続プールを機能ごとに分離する設計です。
すべての外部呼び出しにデフォルトタイムアウトを設定します。無指定は必ず障害の温床になります。
ケース別の実装優先度
全てのパターンを最初から導入すると過剰設計になります。システムの規模と外部依存の数によって、段階的に決めるのが現実的です。
個人開発・社内ツール
例外 + グローバルハンドラ + タイムアウト。これだけで十分。Circuit Breaker などは不要。
一般的なWebサービス
上記 + 相関ID + リトライ(Jitter付き)+ 冪等性。運用開始時点で必要。
マイクロサービス・外部API多用
上記 + Circuit Breaker + Bulkhead + Rate Limit。サービスメッシュ(Istio等)で外側から入れるのが効率的。
決済・金融・在庫系
上記 + 厳格な冪等性 + トランザクション設計(Saga / Outbox)。二重処理は絶対に許されない。
タイムアウト・リトライの数値Gate
※ 2026年4月時点の業界相場値です。テクノロジー・人材市場の変化で陳腐化するため、定期的にアップデートが必要です。
エラー戦略を「適切に」という曖昧言葉で運用すると本番で事故るため、具体的な数値基準を最初に置きます。以下が業界の定番値です。
| 設定項目 | 推奨値 | 理由 |
|---|---|---|
| HTTPクライアント タイムアウト | 接続5秒 / 読み取り30秒 | 無指定は時限爆弾 |
| DB接続タイムアウト | 接続3秒 / クエリ30秒 | DB障害時の雪崩防止 |
| リトライ最大回数 | 3〜5回 | 無限ループは攻撃行為になる |
| 指数バックオフ間隔 | 1→2→4→8秒 + Jitter 0〜1秒 | Thundering Herd防止 |
| Circuit Breaker エラー率閾値 | 50%(直近10秒) | 過敏すぎても鈍すぎてもNG |
| Circuit Breaker 半開復帰時間 | 30〜60秒 | サービス復旧猶予 |
| Bulkhead 並列数 | 外部サービスごとに10〜50 | リソース独立性 |
| Rate Limit(公開API) | 60 req/分/ユーザー | ブルートフォース対策 |
| Idempotency-Key TTL | 24時間 | リトライ可能期間 |
AWS SDK / Google Cloud SDK / Stripe SDK はデフォルトで Jitter 付き指数バックオフを実装しているので、自前実装よりライブラリ任せが鉄則です。Resilience4j(Java)/ Polly(.NET)/ tenacity(Python)/ resilience(TypeScript)が2026年の定番ライブラリです。
タイムアウト無指定は時限爆弾。全ての外部呼び出しに必ず設定します。
エラーハンドリングの鬼門・禁じ手
異常系で事故る典型を整理します。どれもサイレント障害・二重処理・雪崩停止の原因になります。
| 禁じ手 | なぜダメか |
|---|---|
catch (e) { /* 何もしない */ } の握り潰し | サイレント障害の温床。本番で原因不明の不具合が延々と出る |
catch (Exception e) { log(e) } で全例外を同じ扱い | バグと業務エラーが混ざり、アラートが鳴り続けて形骸化 |
| タイムアウト無指定でHTTPクライアント / DB接続 | AWS us-east-1 Kinesis 2020年11月の大規模障害と同じ雪崩パターン |
| リトライに冪等性キーなし | ネットワーク障害で二重決済・二重登録・在庫二重減算 |
| リトライにJitterなし | Thundering Herdで復旧中の外部サービスを再度叩き落とす |
| ユーザーにスタックトレース / SQLエラーを表示 | 攻撃の手がかりになる・業務情報漏洩の可能性 |
return null / return -1 でエラー表現 | 呼び出し側が気付かず、null伝播でやがてNullPointer |
| Circuit Breakerなしで外部APIを叩き続ける | 外部障害時にスレッドプール枯渇→自分も倒れる雪崩連鎖 |
| 相関IDを後付けで導入 | サービス横断のログ追跡が不能。最初から OpenTelemetry で入れる |
エラー型の階層を作らない(全部 Error) | 業務エラー・技術エラー・バグが混在して catch が機能しない |
2012年のKnight Capital事件(45分で4.4億ドル損失)は、古いコードが残った1台サーバーの「エラー処理不備」が発端。エラー設計は「起きてから足す」では絶対に間に合わないことを示した事例です。
「try/catchで囲んだから大丈夫」はAI生成コードの定番の罠。握り潰しか否かをレビューで見抜くのが人間の仕事です。
AI時代の視点
AI駆動開発が前提になると、エラーハンドリングは「AIに任せてはいけない領域の筆頭」です。AIは正常系は書けますが、異常系は想像で書くため、プログラマが想定していない障害パターン(DBフェイルオーバー・外部API遅延・部分的障害)を見落としがち。人間がエラー型の階層と境界を設計し、AIはその枠内で実装する構造が必要です。
| AI時代に有利 | AI時代に不利 |
|---|---|
| Result型・明示的エラー返却 | 暗黙のthrow・catch漏れ |
| 型で表現された業務エラー | string messageだけの汎用例外 |
| OpenTelemetry等の標準計装 | 独自のログフォーマット |
| グローバルエラーハンドラで一括処理 | 各層でバラバラにtry/catch |
AIが書いたコードは「try/catchしているから大丈夫」と見せかけて握り潰していることが非常に多く、レビューで見抜くのが難しい領域です。対策として、型でエラーを表現する設計(Rust的Result型、TypeScript的Discriminated Union(タグ付きUnion、type: "success" | "error" のようにタグで分岐できる型))に寄せると、AIもエラー処理を忘れにくくなります。Circuit Breaker・タイムアウト・冪等性といった定番パターンは「標準ライブラリ任せ」にするのがAI時代の正解です。
よくある勘違い
- try/catchで囲めばOK → 握り潰しの温床。具体的な型でcatchし、「何ができるか」を考えてから捕まえる
- リトライすれば大丈夫 → 冪等性がなければ二重決済の地雷。リトライと冪等性は必ずセット
- Circuit Breakerは大規模サービス専用 → 外部APIを叩く全サービスに必要です。個人開発でも外部依存があれば検討
- エラーメッセージは丁寧に詳しく → ユーザー向けと開発者向けは別物。スタックトレースを画面に出すのは攻撃の手助け
決めるべきこと — あなたのプロジェクトでの答えは?
以下の項目について、あなたのプロジェクトの答えを1〜2文で言語化してみてください。曖昧なまま着手すると、必ず後から「なぜそう決めたんだっけ」が問われます。
- 例外 vs Result型の使い分け方針
- エラー型の階層設計(業務 / 技術 / バグ)
- 相関IDの発行と伝播方式(OpenTelemetry等)
- リトライ方針(バックオフ / Jitter / 最大試行回数)
- 冪等性の実装方式(Idempotency-Keyの持ち方)
- Circuit Breaker / Timeout / Rate Limit の閾値
- ユーザー向けエラーメッセージのフォーマット
- ログレベル(ERROR / WARN / INFO / DEBUG)の使い分けルール
最終的な判断の仕方
エラー設計の核心は「落ちないシステムではなく、落ちても復旧できるシステムを作ること」です。どんなに堅牢に書いてもネットワークは切れ、外部APIは落ち、DBはフェイルオーバーします。
選定の出発点はエラーの種類ごとに扱いを変えること。プログラムバグは修正対象、入力エラーはユーザーに戻す、業務エラーは業務フローで処理、一時障害はリトライ、恒常障害は即失敗。これを共通の Error で一括処理するのが最悪のアンチパターンで、サイレント障害と二重処理事故の温床になります。「境界で集約処理する・型で分類する・相関IDで追跡する」、この3つが基盤です。
決定的な軸は「AIに異常系を任せない」ことです。AIは正常系は書けますが、異常系は想像で書くため、DBフェイルオーバー・外部API遅延・部分的障害のような現実の障害パターンを見落とします。
さらに悪いことに「try/catchで囲んだから大丈夫」と見せかけて例外を握り潰すコードを平気で生成します。対策は「型でエラーを縛る」こと。Result型・Discriminated Unionで返却を強制し、リトライ・Circuit Breaker・タイムアウトは標準ライブラリ(Resilience4j / Polly / OpenTelemetry)に任せます。異常系の想像力は人間の仕事として残し、実装のボイラープレートだけAIに任せるのが正しい分業です。
選定の優先順位をまとめると次の通りです。
- エラーを種類で分類する(バグ/入力/業務/一時障害/恒常障害)
- 境界で集約処理する(グローバルハンドラで変換)
- リトライ + 冪等性 + Circuit Breakerの三点セット(外部依存の必須装備)
- 型でエラーを縛る(Result型 / Discriminated Unionで握り潰し防止)
まとめ
本記事はエラーハンドリングについて、エラー分類・例外vsResult型・リトライ戦略・冪等性・Circuit Breakerまで含めて解説しました。如何だったでしょうか。
異常系の想像力は人間の仕事。AIには型と標準ライブラリで縛りをかけるのが2026年のエラー設計の現実解です。
これで「アプリケーションアーキテクチャ」カテゴリ全5記事が完結しました。次回からは「フロントエンドアーキテクチャ」カテゴリに入り、ホスティング・レンダリング・状態管理・SEOなどフロントエンドの設計判断を解説していきます。
シリーズ目次に戻る → 『生成AI時代のアーキテクチャ超入門』の歩き方
それでは次の記事も閲覧いただけると幸いです。
📚 シリーズ:生成AI時代のアーキテクチャ超入門(29/89)