有名なWebフレームワークのエラーハンドリングの仕様を調査し、自社用の開発テンプレートハーネスに反映してみた

有名なWebフレームワークのエラーハンドリングの仕様を調査し、自社用の開発テンプレートハーネスに反映してみた

社内の開発テンプレート設計でエラーハンドリングの正当性を評価するために、Rails・Django・NestJSの実ソースコードを読んで、3大フレームワークの設計判断を調査・比較してみました。
2026.07.12

リテールアプリ共創部のるおんです。

最近、社内の 開発テンプレート(ハーネス) を整備しています。新しい案件を始めるときの土台になるリポジトリで、アーキテクチャやテスト・lint・エラーハンドリングといった「プロジェクトの品質を支える規約」を、ドキュメントではなく 動くコードの形 で配るためのものです。

このテンプレートは TypeScriptで統一したフルスタック構成 で、フロントエンドは React 、バックエンドは Hono 、インフラは AWS CDK(Lambda や ECS などのコンピューティングを想定)で書いています。中身はこんなモノレポです。

dev-template/
├── web/      # React。features/{ドメイン}/ 単位で画面・hooks・スキーマをまとめる
│             # フィーチャーベース構成
├── server/   # Hono。handler / use-case / domain / infrastructure の
│             # クリーンアーキテクチャ構成
├── infra/    # AWS CDK。環境別のconfigでログレベル等を注入する
└── eslint.config.base.mjs   # ワークスペース共通のlint規約

テンプレートは、題材として シンプルなTodoアプリ が実装された状態で配っています。空のひな形ではなく「規約に沿った動く実装例」が最初から入っていることで、新しい案件ではTodoの部分を自分たちのドメインに置き換えるだけで、同じ構造のまま開発を始められます(この記事のコード例にTodoが登場するのはこのためです)。

ポイントは、こうしたアーキテクチャの規約を「ドキュメントに書いて守ってもらう」のではなく、できる限り ESLintの設定で機械的に強制している ことです。バックエンドはクリーンアーキテクチャで層ごとの依存の向きを整理し、フロントエンドはfeature同士の直接importをlintエラーにしています(共有したくなったら共有置き場へ昇格させるルール)。規約が人の注意力ではなく仕組みで守られているので、誰が書いても(人間でもAIでも)同じ構造・同じ品質に収束します 。これが「ハーネス」と呼んでいる理由です。

ただ、この構成には1つ悩みどころがあります。Rails・Django・NestJS のようなフルスタック系フレームワークは、エラーハンドリングの機構(例外→HTTP応答への翻訳・ログ・エラーページ)を最初から内蔵 しています。一方、私たちが採用している Hono は軽量・高速が持ち味で、いい意味で「決め」が少ないフレームワークです。つまり エラーの型をどう作るか、どこでまとめて捕まえるか、どうログするか、を全部自分たちで設計する必要があります

実際に自作してみて、「未知の例外は500に丸める」「エラーの共通捕捉は1箇所に集約する」「クライアント起因の4xxエラーは低いログレベルで記録し、想定外の5xxエラーだけを高いレベルで通知する」といった規約にたどり着いたのですが、ふと気になりました。フレームワークが与えてくれるはずだったものを自作した以上、この設計は世間的に見て妥当なんだっけ? 独自ルールを全案件に配ってしまう前に、エラーハンドリングを何年も磨いてきた有名フレームワークの実装 と答え合わせがしたくなったのです。

そこで今回は、Ruby on Rails・Django(+ DRF)・NestJS の3つの有名フレームワークを実際にインストールし、フレームワーク本体のソースコードを読んで 、それぞれのエラーハンドリング戦略を調査しました。ドキュメントの要約ではなく、実装コードを一次情報 として突き合わせます。そして、その結果を自社テンプレートに反映するところまでやってみました。

先に結論

3フレームワークの実装を7つの観点で比較した結果、フレームワークが違っても揃って同じ判断をしている「業界の合意」が5つ 見えてきました。

  • 未知の例外は必ず500へ丸める(迷ったら安全側に倒す)
  • 共通捕捉は1箇所に集約する(ミドルウェア/フィルタの最外層で例外→HTTP応答に翻訳)
  • ログレベルはエラーの種類で分ける(クライアント起因の4xxは低いレベルで記録し、想定外の5xxだけを高いレベルで通知して監視に乗せる)
  • 開発環境では詳細なエラー情報を表示し、本番環境では隠す(スタックトレース等の内部詳細を本番のレスポンスに入れない)
  • エラーボディは各者独自形式(RFC 7807 / problem+json を既定採用しているものは無かった)

一方で、フレームワークごとに答えが割れている部分 もあり、そこは「正解が1つではない、プロジェクトごとに決めてよい領域」だと分かりました。

観点 Rails Django + DRF NestJS
例外→ステータスの決め方 宣言的マップ(クラス名→ステータスの辞書に登録) 捕捉側の分岐(isinstanceで翻訳) 例外クラスに内蔵(statusとbodyを持つ)
エラーボディ {status, error} {"detail": ...} {statusCode, message, error}
バリデーションエラーの形 422 + メッセージ配列(自動整形なし) 400 + フィールド別のdict 400 + フラットな文字列配列

自社テンプレートの規約は、結果として 合意5点のすべてに整合 していました。つまり大改造は不要でした。「直すところが無かった」と自信を持って言えるようになったこと自体が、今回いちばんの収穫 です。詳細は後半で紹介します。

調査の方法:ドキュメントではなくソースを読む

今回の調査で決めていたことが1つあります。公式ドキュメントの説明ではなく、インストールした実物のソースコードを一次情報にする ことです。ドキュメントは「どう使うか」を教えてくれますが、「境界ケースで実際にどう振る舞うか」はコードにしか書かれていません(以前、ドキュメントの記述と実物の挙動が食い違って混乱した経験もあり、実物主義は徹底したいところです)。

確認した実物は以下です。

フレームワーク バージョン 確認方法
Ruby on Rails 7.1.2 インストール済みgem(actionpack / activesupport / activerecord)のソースを直接読解
Django + DRF 5.2.16 + 3.17.1 venvへ実インストールし、site-packagesのソースを読解。DRFの挙動は 実際に実行して裏取り
NestJS 11.1.28 npmで取得し、node_modules内の実装を読解

ちなみにこの調査、3フレームワークを1つずつ順番にやると大変なので、Claude Codeのサブエージェントを3並列 で走らせました。各エージェントに「実物をインストールし、指定の7観点を実コード引用付きで報告せよ。推測禁止」という縛りをかけて同時に調査させ、報告を突き合わせる形です。並列で走らせたおかげで、調査自体は1時間もかかっていません。

各フレームワークのエラーハンドリング戦略

まず、3フレームワークそれぞれの「設計の性格」を、実コードと一緒に見ていきます。

Rails:宣言的マップと「登録しない限り全部500」

Railsの核は、例外クラス名→HTTPステータスの宣言的なマップrescue_responses)です。面白いのはその初期値で、デフォルト値を500とするHash に、既知の例外だけが登録されています。

actionpack-7.1.2/lib/action_dispatch/middleware/exception_wrapper.rb
cattr_accessor :rescue_responses, default: Hash.new(:internal_server_error).merge!(
  "ActionController::RoutingError"             => :not_found,
  "ActionController::ParameterMissing"         => :bad_request,
  "ActionController::InvalidAuthenticityToken" => :unprocessable_entity,
  # ...

https://github.com/rails/rails/blob/v7.1.2/actionpack/lib/action_dispatch/middleware/exception_wrapper.rb

Hash.new(:internal_server_error) がすべてを物語っています。マップに登録されていない例外は、何もしなくても自動的に500になります 。「迷ったら500」という安全側の判断が、データ構造の初期値として表現されているわけです。

もう1つRailsらしいのが、このマップが 登録式でオープン なことです。ActiveRecordは自分の例外(RecordNotFound → 404 など)を、railtie経由で後からこのマップに追記します。アプリも同じ方法で独自例外を登録できます。プラグイン(gem)の生態系を持つフレームワークならではの設計です。

https://github.com/rails/rails/blob/v7.1.2/activerecord/lib/active_record/railtie.rb

捕捉はミドルウェアの2層構え(開発向けの DebugExceptions と本番向けの ShowExceptions)で、開発では詳細画面、本番では public/500.html や最小のJSON({"status":500,"error":"Internal Server Error"})を返します。同じ機構を環境スイッチ(consider_all_requests_local)で「開発では詳細表示・本番では最小限の応答」に切り替える 構図です。

Django:共通の1箇所で例外を判定して翻訳する

Djangoは、例外クラス自体にはステータスを持たせません。代わりに、全ミドルウェアを包む共通関数 response_for_exception が、例外の型をisinstanceで順に判定してHTTPへ翻訳 します。

django/core/handlers/exception.py
if isinstance(exc, Http404): ...            # → 404
elif isinstance(exc, PermissionDenied): ... # → 403
elif isinstance(exc, SuspiciousOperation): ... # → 400
else:  # 未知の例外はすべて500へ
    signals.got_request_exception.send(sender=None, request=request)
    response = handle_uncaught_exception(...)

https://github.com/django/django/blob/5.2.16/django/core/handlers/exception.py

ここでも else節=未知の例外は500 が明確です。そして見逃せないのが、500に落とす直前の got_request_exception シグナルです。これは Sentryのような監視ツールがあとから接続するための拡張ポイント です。「未知のエラーが起きた瞬間」を外部に通知するための差し込み口を、フレームワーク本体が最初から用意しているわけです。

API開発で使うDRF(Django REST Framework)は、この上にもう一層乗ります。APIException を基底に、クラス属性でステータスとデフォルトメッセージを持つ例外階層ValidationError=400、NotFound=404、Throttled=429…)を定義し、exception_handler がボディを {"detail": ...} 形式に整形します。

https://github.com/encode/django-rest-framework/blob/3.17.1/rest_framework/exceptions.py

https://github.com/encode/django-rest-framework/blob/3.17.1/rest_framework/views.py

バリデーションエラーの形は、3つの中でいちばんプログラムから扱いやすい形でした。フィールド名→エラー配列のdict で、各エラーには code まで付きます(これは実際にシリアライザを実行して確認しました)。

実行して確認したDRFのバリデーションエラー(400のボディ)
{'name': [ErrorDetail('This field is required.', code='required')],
 'age':  [ErrorDetail('Ensure this value is greater than or equal to 0.', code='min_value')]}

ログは django.request ロガーに 4xx=warning / 5xx=error の二段階で記録され、500はさらに AdminEmailHandler で管理者へメール通知されます。「4xxは低いレベルで記録し、5xxは通知まで上げる」という運用が既定で組み込まれているわけです。

NestJS:例外を投げれば応答になる、最後の砦のフィルタ

NestJSは3者の中でいちばん「例外を投げれば応答になる」に振り切っています。基底の HttpExceptionステータスとレスポンスボディをインスタンスに内蔵 し、NotFoundException などの派生はステータスを固定しただけの薄いラッパーです。

https://github.com/nestjs/nest/blob/v11.1.28/packages/common/exceptions/http.exception.ts

そして最終防衛線が BaseExceptionFilter です。HttpExceptionでない例外(=想定外)は、問答無用で500+固定文言に丸めます

@nestjs/core/exceptions/base-exception-filter.js
catch(exception, host) {
  if (!(exception instanceof HttpException)) {
    return this.handleUnknownError(exception, host, applicationRef);
  }
  // 想定内のHttpExceptionはstatusとbodyをそのまま応答へ

https://github.com/nestjs/nest/blob/v11.1.28/packages/core/exceptions/base-exception-filter.ts

ログの思想も明快で、想定内のHttpException(4xx等)は一切ログせず、未知の例外だけを logger.error する 実装でした。「ログに出ているものはすべて調査対象」という状態を保つ、かなり潔い割り切りです。

もう1点、調査前に気になっていた「RFC 7807(application/problem+json。HTTPエラーボディの標準形式)に対応しているのか」は、node_modules全体をgrepして 0件=組み込み対応なし を確認しました。エラーボディは独自の {statusCode, message, error} 形式です。

7観点の比較

3つのフレームワークと自社テンプレートを、観点ごとに並べて比較します。

1. 例外の型体系

どうしているか
Rails 例外クラスは自由。クラス名→ステータスの 宣言的マップ に登録する(登録式)
Django + DRF 本体は素の例外階層+ 捕捉側のisinstance分岐 。DRFは APIException 階層
NestJS HttpException にstatusとbodyを内蔵
自社テンプレート 基底クラスがステータスを決め、ユースケース派生が名前とメッセージを持つ

ここは3フレームワークで見事に方式が割れている観点です(クラスに埋める/マップに登録する/捕まえる側で分岐する)。

2. 共通捕捉の場所

どうしているか
Rails ミドルウェア2層(DebugExceptions → ShowExceptions)
Django + DRF response_for_exception の一点+DRFの exception_handler
NestJS BaseExceptionFilter が最終防衛線
自社テンプレート エラーレスポンスビルダーの一点(Honoの共通エラーハンドラから呼ぶ)

3. 開発/本番でのエラー情報の出し分け

どうしているか
Rails consider_all_requests_local でスイッチ
Django + DRF DEBUG で技術詳細ページ⇔本番ハンドラを切り替え
NestJS 明示的な仕組みは持たない
自社テンプレート CDKの環境別configでログレベルを注入(レスポンスは常に本番相当)

4. エラーボディの形

どうしているか
Rails {status, error}(最小)
Django + DRF {"detail": ...}
NestJS {statusCode, message, error}
自社テンプレート {error: エラークラス名, message}

全員が独自形式です。RFC 7807を既定採用しているものはありませんでした。

5. バリデーションエラーの形

どうしているか
Rails 422+メッセージ配列(アプリが自前でrenderする)
Django + DRF 400+ フィールド別dict(code付き)
NestJS 400+フラットな文字列配列
自社テンプレート 400+{error, message}(フィールド別なし。クライアントのzodが先取り検証するため)

6. ログとの接続

どうしているか
Rails ミドルウェアが自動で1回ログする。ルーティングエラー等はレベルを下げる
Django + DRF 4xx=warning / 5xx=error +500は管理者へメール通知
NestJS 想定内の4xxはログしない 。未知の例外だけerror
自社テンプレート 4xx=info / 5xx=error +ERRORログはSlackへ通知

7. 未知の例外の扱い

どうしているか
Rails Hash.new(:internal_server_error) で自動的に500
Django + DRF else節で500+観測ツール用のシグナル送出
NestJS handleUnknownError で500+固定文言
自社テンプレート else節で500+固定文言(ERRORログ=アラーム対象)

ここは実装こそ違えど、全員が「500に丸める」で一致 しています。

こうして並べると、自社テンプレートは 要所(共通捕捉・ログの分け方・未知例外の扱い)では3フレームワークと同じ判断 をしていて、割れている部分(型体系・ボディの形)では自分たちなりの選択をしている、という位置づけが見えてきます。この答え合わせを後半でやります。

見えてきた「業界の合意」5点

比較して面白かったのは、設計スタイルがこれだけ違うのに、要所では全員が同じ判断をしている ことです。3者が揃って同じ選択をしているなら、それは事実上の業界合意とみなしてよいはずです。

  1. 未知の例外は必ず500へ丸める 。Railsは辞書のデフォルト値、Djangoはelse節、NestJSは最終防衛線と、実装は三者三様ですが、「登録・分類されていない例外=バグの可能性があるものは、安全側の500へ」という原則は完全に一致していました
  2. 共通捕捉の一点集中 。個別のtry/catchを散らさず、最外層の1箇所で「例外→HTTP応答」の翻訳を行う。アプリケーションコードは例外を投げるだけでよい
  3. ログレベルをエラーの種類で分ける 。クライアント起因の4xxは想定内のイベントなので低いレベル(info/warning)で記録に留め、想定外の5xxだけを高いレベル(error)で監視・通知に乗せる。NestJSに至っては想定内の例外を一切ログしません
  4. 開発環境では詳細なエラー情報を表示し、本番環境では隠す 。スタックトレース等の内部詳細を本番のレスポンスボディに入れない。詳細はサーバー側のログにだけ残す
  5. エラーボディは独自形式でよい 。標準化の試みであるRFC 7807を既定採用しているフレームワークは1つもありませんでした。「ボディの形はプロジェクトで決めてよい」が現実のようです

逆に 答えが割れていた部分(例外→ステータスの決め方と、バリデーションエラーのボディの形)は、フレームワークの性格による選択で、「唯一の正解」は無い領域だと分かりました。

自社テンプレートへの反映

さて、本題の答え合わせです。冒頭で書いたとおり、Honoにはエラーハンドリングの決まった仕組みが無いので、フレームワークが内蔵している機構に相当するもの を自社テンプレートでは自作しています。対応関係を整理すると、こうなります。

フレームワークの機構 自社テンプレートでの自作対応物
例外の型体系(DRFの APIException 階層 等) 基底エラークラス階層AppError → カテゴリ基底 → ユースケース派生)
共通捕捉(NestJSの BaseExceptionFilter、Djangoの response_for_exception エラーレスポンスビルダー(Honoの共通エラーハンドラから呼ぶ一点)
環境によるエラー情報の出し分け(Djangoの DEBUG 等) CDKの環境別config でログレベルを注入

実物のコードも載せてしまいます。まずは 型体系 です。カテゴリごとの基底クラスがHTTPステータスに対応し、ユースケース固有のエラーはそれを継承して名前とメッセージだけを持ちます。

server/src/util/error-util.ts(抜粋)
/** 各層で使うエラーの基底クラス */
export class AppError extends Error {
  readonly context?: Record<string, unknown>; // 調査用の値。ログに乗る
  constructor(message: string, options?: AppErrorOptions) {
    super(message, options);
    this.context = options?.context;
  }
}

export class NotFoundError extends AppError {   // → 404
  override name = "NotFoundError";
  constructor(message = "対象のリソースが見つかりませんでした。", options?: AppErrorOptions) {
    super(message, options);
  }
}

export class ConflictError extends AppError {   // → 409
  override name = "ConflictError";
  constructor(message = "他の操作と競合しました", options?: AppErrorOptions) {
    super(message, options);
  }
}
ユースケース固有の派生エラーの例
/** Todoの登録数が上限に達しているときのエラー */
export class TodoLimitExceededError extends ConflictError {
  override name = "TodoLimitExceededError";
  constructor(options?: ConstructorParameters<typeof ConflictError>[1]) {
    super("Todoの登録上限に達しています", options);
  }
}

このエラーを 投げる側 のユースケースはこうなります。

server/src/use-case/create-todo-use-case.ts(抜粋)
export class CreateTodoUseCaseImpl implements CreateTodoUseCase {
  async execute({ userId, title, description, dueDate }: CreateTodoUseCaseInput) {
    const todos = await this.#todoRepository.findAllByUserId({ userId });
    if (todos.length >= TODO_LIMIT) {
      throw new TodoLimitExceededError(); // 投げるだけ。ここではcatchしない
    }

    // ...Todoを作成して保存する
  }
}

見てのとおり、Result型のような仕組みは使わず、素直にthrowするだけ です。ユースケースの中にtry/catchも書きません。投げた例外は途中の層を素通りして、一番外側の共通ハンドラまで届きます。この「途中で誰も捕まえない」ルールのおかげで、エラー処理コードが各層に増殖しない ようになっています。

では、投げっぱなしの例外を誰が受け止めるのか。次が 共通捕捉 です。エラーレスポンスビルダーが、基底クラスのinstanceofで分岐してステータスを決めます。ユースケースがどれだけ派生エラーを増やしても、ここは1行も変わりません 。そして分類できない例外は、else節で500に倒してERRORログ(=アラーム対象)に乗せます。

server/src/presenter/error-response-builder.ts(抜粋)
if (error instanceof InvalidError) {
  logger.info("入力不正エラー", error);       // 4xx=想定内なのでinfoで記録に留める
  return { httpStatus: 400, body: { error: error.name, message: error.message } };
} else if (error instanceof NotFoundError) {
  logger.info("リソースが見つからないエラー", error);
  return { httpStatus: 404, body: { error: error.name, message: error.message } };
} else if (error instanceof ConflictError) {
  logger.info("他の操作と競合したエラー", error);
  return { httpStatus: 409, body: { error: error.name, message: error.message } };
} else {
  // 500番台はerrorオブジェクトの内容から原因を推測され攻撃のヒントになるため、
  // 汎用的で無害のメッセージを返却する
  logger.error("予期せぬエラー", error);      // 想定外だけerrorで通知(アラーム対象)
  return { httpStatus: 500, body: { error: "InternalServerError", message: UNEXPECTED_ERROR_MESSAGE } };
}

ちなみに、この logger.error は単なるログ出力では終わりません。CDK側でLambdaのロググループに「ログレベルがERRORのログ」を拾うメトリクスフィルタを仕込んであり、そこからCloudWatchアラーム → SNS → AWS Chatbot経由で Slackに通知 が飛ぶようにしています。つまり、4xxの logger.info は記録に残るだけ、想定外の500の logger.error だけがSlackに届きます。「ログレベルをエラーの種類で分ける」という合意が、通知が来るか来ないかという運用の差 にそのまま繋がっているわけです。

最後に 環境によるエラー情報の出し分け です。Djangoの DEBUG やRailsの consider_all_requests_local に相当するスイッチは、私たちの場合CDKの環境別configです。ログレベルを型で制約し、環境ごとの値をLambdaの環境変数として注入しています(開発はDEBUG、本番はINFO以上に絞る想定)。

infra/config-type.ts と 環境別config(抜粋)
// 型でレベルを制約
logLevel: "DEBUG" | "INFO" | "WARN" | "ERROR";

// 開発環境のconfig
logLevel: "DEBUG",
infra/lib/stack/server-stack.ts(抜粋)
lambdaEnv: {
  // 環境別configの値をLambdaへ注入。serverのloggerはこのレベル未満のログを出さない
  LOG_LEVEL: props.config.logLevel,
  // ...
},

これらを調査結果と突き合わせると、合意5点にはすべて整合 していました。特に「未知の例外を401や400に誤分類せず500に倒す(=バグをアラームに乗せる)」という、社内レビューで議論になったポイントが、3フレームワーク共通のフェイルセーフ原則と一致していたのは大きな確証になりました。「独自に考えた規約」のつもりだったものが、実は有名フレームワークたちが何年もかけて行き着いた形と同じだった、というのは設計の自信になります。

ちなみに、この調査レポート自体も比較表と実コード引用ごとテンプレートのプレイブック(設計ドキュメント)に収録しました。次にエラーハンドリングの設計判断で迷ったとき、「業界はどうしているか」に3分でアクセスできる状態にしておくためです。

おわりに

今回は、Rails・Django・NestJSを実際にインストールして本体ソースを読み、エラーハンドリング戦略を比較して、自社の開発テンプレートに反映してみました。

振り返ると、学びはこのあたりです。

  • 成熟したフレームワークは「設計判断の宝庫」 。何年も本番で使われて磨かれた判断がコードに残っており、自分たちの設計の答え合わせ相手として最高の教材になる
  • 3者が揃って同じ判断をしている部分は、事実上の業界合意 とみなせる。未知例外の500丸め・共通捕捉の一点集中・エラー種別によるログレベルの分離・本番で内部詳細を隠すこと・独自ボディの許容、の5点は安心して従ってよい
  • 答えが割れている部分は、プロジェクトごとに決めてよい 。例外→ステータスの決め方やバリデーションエラーの形は、プロジェクトの性格で選んでよい(ただし選んだ理由は規約に書き残す)
  • 調査はドキュメントでなくソースで 。「RFC 7807対応は無い」のような「無いことの確認」は、grepで実測して初めて断言できる。サブエージェントを並列で使えば、この手の実地調査は1時間かからない

自社のテンプレートやプロジェクトで「このエラー設計でいいんだっけ?」と迷っている方の、答え合わせの参考になれば幸いです。

以上、どなたかの参考になれば幸いです。

参考

https://api.rubyonrails.org/classes/ActionDispatch/ExceptionWrapper.html

https://docs.djangoproject.com/en/5.2/ref/views/#error-views

https://www.django-rest-framework.org/api-guide/exceptions/

https://docs.nestjs.com/exception-filters

https://datatracker.ietf.org/doc/html/rfc7807

この記事をシェアする

関連記事