ウェブエンジニア問題集
第10章

エラー設計 — エラーコード体系・ユーザー通知・ログ戦略

11
この章の目次開く

エラーは「起きないように頑張る」ものではなく、必ず起きる前提で扱いを設計するものです。ネットワークは切れ、外部APIは落ち、ユーザーは想定外の入力をします。

エラー設計をせずに実装を始めると、「とりあえず try-catch で握りつぶす」「どこでも 500 を返す」「ユーザーにスタックトレースが見える」といった場当たり的な処理がコードベース中に散らばります。この章では、エラーの分類・エラーコード体系・ユーザー通知とログの分離・非同期処理のエラー戦略を順に設計していきます。

学習者学習者

エラー処理って try-catch で囲んでおけばいいんじゃないの?設計するようなことなの?

先生先生

try-catch は「捕まえる道具」でしかない。捕まえた後に「誰に・何を・どう伝えるか」——ユーザーへの表示、開発者へのログ、呼び出し元への通知——を決めるのがエラー設計だよ。ここを決めずに書くと、エラーのたびに各自がバラバラの処理を書くことになる。

エラーを分類する — すべてのエラーは同じではない

エラー設計の出発点は分類です。性質の違うエラーを同じように扱うことが、多くの混乱の原因になります。

予期するエラーと予期しないエラー

分類扱い方
予期するエラー(業務エラー)入力値の不正、在庫切れ、権限不足、重複登録正常系の一部として設計する。ユーザーに「どうすれば解決するか」を伝える
予期しないエラー(システムエラー)DB接続断、外部APIのタイムアウト、バグによる例外ユーザーには汎用メッセージを返し、詳細はログに記録して開発者が対応する
予期するエラーは「ユーザーが解決できる」、予期しないエラーは「開発者しか解決できない」——この違いが、メッセージ・ログ・通知先のすべてを分けます。

「在庫切れ」をシステム障害のように 500 で返すのも、「DB接続断」をユーザーの操作ミスのように扱うのも、どちらも分類の失敗です。

エラーコード体系の設計

HTTPステータスコード(API設計の基本で扱いました)は「エラーの大分類」しか表現できません。400 Bad Request だけでは、フロントエンドは「どの項目が」「なぜ」ダメなのかを判別できません。

そこで、アプリケーション独自のエラーコードを設計します。

{
  "error": {
    "code": "ORDER_STOCK_SHORTAGE",
    "message": "在庫が不足しています",
    "details": [{ "productId": 42, "requested": 3, "available": 1 }]
  }
}
json

エラーコードの設計ルール

  • 機械可読な文字列にするE1042 のような番号より ORDER_STOCK_SHORTAGE のような英語の定数名の方が、コード検索できてログも読みやすい
  • ドメイン_内容 の形式で命名を統一するUSER_NOT_FOUNDAUTH_TOKEN_EXPIREDORDER_STOCK_SHORTAGE のように、どの機能領域のエラーかが先頭で分かるようにする
  • 一覧をドキュメント化する — エラーコードはAPIの契約の一部。フロントエンドはコードを見て分岐するため、勝手に変更・削除しない
  • HTTPステータスとの対応を決めておく — 「業務エラーは 400422、認証は 401、認可は 403」のような対応表を作り、個人の裁量で選ばせない

TypeScriptでは、エラーコードを持つ独自のエラークラスを定義しておくと、throwする側と処理する側の契約が明確になります。

// アプリ全体で使う基底エラークラス
class AppError extends Error {
  constructor(
    public readonly code: string, // 機械可読なエラーコード
    public readonly statusCode: number, // 対応するHTTPステータス
    message: string // 開発者向けの説明(ログ用)
  ) {
    super(message);
  }
}
 
class StockShortageError extends AppError {
  constructor(productId: number) {
    super('ORDER_STOCK_SHORTAGE', 409, `商品${productId}の在庫が不足`);
  }
}
 
// 使う側: 業務ロジックは「何が起きたか」をthrowするだけ
if (stock < requested) {
  throw new StockShortageError(product.id);
}
ts

ユーザー向けメッセージとログの分離

エラーが起きたとき、伝えるべき相手は2人います。ユーザー開発者です。この2人は必要とする情報がまったく違います。

観点ユーザー向けメッセージログ(開発者向け)
目的次に何をすればよいかを伝える原因を特定して修正する
内容平易な言葉。解決方法・問い合わせ先スタックトレース、リクエスト内容、変数の値
「時間をおいて再度お試しください」ECONNREFUSED db:3306 at OrderRepository.insert
エラー画面に驚くユーザー
スタックトレースを見せられても、ユーザーには何もできない

やってはいけないのは、この2つを混ぜることです。

❌ ユーザーに内部情報を見せる
「Error: connect ECONNREFUSED 10.0.1.5:3306」
→ ユーザーは意味が分からず不安になるだけ。さらに内部のIPアドレスや
  使用技術が漏れ、攻撃者へのヒントになる(セキュリティ問題)

❌ ログに詳細がない
logger.error('エラーが発生しました')
→ 開発者が調査できない。「どのリクエストで・誰が・何をして」が必要
ユーザーには「解決方法」を、ログには「原因特定の材料」を——同じエラーから2種類の出力を作るのがエラー設計の基本形です。

先ほどの AppError を使うと、この分離をエラーハンドリングの共通処理(Expressならエラーハンドリングミドルウェア)で一元化できます。

// エラー処理の一元化: 個々のルートハンドラにはtry-catchを書かせない
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
  if (err instanceof AppError) {
    // 予期するエラー: コードに応じたメッセージをユーザーへ
    logger.warn({ code: err.code, path: req.path, message: err.message });
    return res.status(err.statusCode).json({
      error: { code: err.code, message: userMessageFor(err.code) },
    });
  }
 
  // 予期しないエラー: 詳細はログのみ。ユーザーには汎用メッセージ
  logger.error({ path: req.path, stack: err.stack, requestId: req.id });
  return res.status(500).json({
    error: { code: 'INTERNAL_ERROR', message: '一時的な問題が発生しました。時間をおいて再度お試しください。' },
  });
});
ts

ログ戦略 — 何を・どのレベルで記録するか

ログは「書けば書くほど良い」ものではありません。ノイズが多いログは、障害時に本当に必要な情報を埋もれさせます。設計段階で決めるのはレベルの使い分け必ず含める情報です。

ログレベルの使い分け

レベル用途
error開発者の対応が必要な問題未捕捉の例外、DB接続断、外部API連続失敗
warn即対応は不要だが注視すべき事象業務エラーの多発、リトライ発生、非推奨機能の使用
info正常系の重要イベント起動・停止、決済完了、外部連携の実行
debug開発時の調査用(本番では通常出さない)関数の入出力、SQL、中間状態
「errorレベルのログが出たら人が対応する」という運用ルールとセットで決める——対応不要なものをerrorで出し続けると、本当のエラーが無視されるようになります。

構造化ログと相関ID

ログは文章ではなく、機械で検索・集計できる**JSON形式(構造化ログ)**で出すのが現在の主流です。そして1つのリクエストに関するログを追跡できるよう、**リクエストID(相関ID)**をすべてのログに含めます。

{
  "level": "error",
  "time": "2026-07-04T10:23:45Z",
  "requestId": "req_8f3a1c",
  "userId": 123,
  "code": "ORDER_STOCK_SHORTAGE",
  "path": "/api/orders",
  "message": "商品42の在庫が不足"
}
json

障害調査は「ユーザーからの問い合わせ → requestIdの特定 → そのリクエストの全ログを時系列で追う」という流れになります。requestIdがないと、大量のログから該当リクエストの記録を推測で探すことになります。

非同期処理のエラー戦略

学習者学習者

try-catch で囲んでいるのにエラーが捕まえられないことがあって…。非同期処理だと何かが違うの?

先生先生

それは典型的なハマりどころ。try-catch が捕まえられるのは「同じ実行の流れの中で」投げられたエラーだけ。await を忘れたPromiseの失敗は、tryブロックを抜けた後に起きるから捕まえられないんだ。

// ❌ awaitがないため、sendMail内のエラーはこのtry-catchを素通りする
try {
  sendMail(user.email); // Promiseを待っていない
} catch (e) {
  // ここには到達しない
}
 
// ⭕ awaitすればcatchできる
try {
  await sendMail(user.email);
} catch (e) {
  logger.warn({ code: 'MAIL_SEND_FAILED', userId: user.id });
}
ts

「待たない処理」のエラーはどこで受けるか

一方で、意図的に完了を待たない処理(メール送信、通知、バッチ処理など)もあります。この場合は「エラーをどこで受けて、どうリカバリするか」を設計で決めておきます。

  • 失敗しても本処理を止めないなら、その処理の中で必ずcatchしてログに残す(放置するとプロセスを落とす未処理のPromise拒否になる)
  • 失敗したら後で再実行したいなら、ジョブキューに積んでリトライする。リトライ間隔は徐々に延ばし(エクスポネンシャルバックオフ)、上限回数を超えたものは失敗キュー(デッドレターキュー)に隔離して人が調査する
  • リトライしてよいのは、再実行しても副作用が重複しない処理だけ。決済のような処理を単純リトライすると二重課金の危険がある(べき等性の担保が必要)
非同期処理では「失敗に誰も気づかない」ことが最悪のシナリオ——catchしてログに残すところまでが実装の最低ラインです。

よくあるハマりどころ

エラーの握りつぶし

// ❌ 最悪のパターン: エラーが起きた事実ごと消える
try {
  await updateUserProfile(data);
} catch (e) {
  // 何もしない
}
ts

「とりあえず落ちないように」と空のcatchを書くと、障害が静かに進行します。ユーザーは「保存したはずのデータが消えている」と後から気づき、開発者には手がかりが何も残りません。捕まえたら、最低限ログに残す。対処できないなら、握りつぶさずに上位へ投げ直します。

すべてを500で返す

分類を設計していないプロジェクトでは、バリデーションエラーも在庫切れも 500 Internal Server Error で返されがちです。フロントエンドは原因を判別できず「エラーが発生しました」としか表示できなくなり、監視上も「本物の障害」と「日常的な業務エラー」が区別できなくなります。

エラーメッセージの実装依存

ユーザー向けメッセージにORMや外部ライブラリのエラー文言をそのまま流すと、ライブラリの更新でメッセージが変わり、フロントエンドの文言分岐が壊れます。フロントエンドが分岐に使ってよいのはエラーコードだけ、と決めておくのが安全です。

ちゃんと使うためのポイント

  • エラー設計とは「誰に・何を・どう伝えるか」を決めること——try-catchは道具にすぎない
  • まず「予期するエラー(ユーザーが解決できる)」と「予期しないエラー(開発者しか解決できない)」に分類する
  • エラーコードは機械可読な文字列(ドメイン_内容)で統一し、APIの契約としてドキュメント化する
  • ユーザーには解決方法を、ログには原因特定の材料を——内部情報をユーザーに見せない
  • ログは構造化(JSON)+ リクエストIDで追跡可能にする。個人情報・秘密情報は出さない
  • 非同期処理は「エラーをどこで受けるか」を必ず決める。握りつぶしと未処理のPromise拒否を作らない

エラー設計の中でも「入力値の不正」は、発生頻度が圧倒的に高い予期するエラーです。次の章では、これを多層で防ぐバリデーション設計を解説します。

参考リンク