エラーハンドリングの設計:「何が起きたか」と「どう返すか」を分離する
Published on 2026年4月18日
設計はじめに
エラーハンドリングを書くたびに、どこか妥協している感覚があった。
throw new HTTPException(409, "...") のように、アプリケーションのロジックの中に HTTP ステータスコードが混ざってくる。ドメイン層が HTTP の知識を持ってしまっているし、プレゼンテーション層を差し替えたら全部書き直しになる。逆にルートハンドラで try/catch を並べると、今度は各ハンドラが「どのエラーが飛んでくるか」を個別に知らなければいけない。
この記事は、「何が起きたか」と「どう返すか」を明確に分離し、各レイヤーからは HTTP の知識を完全に追い出すためのエラーハンドリング設計についてのメモである。
エラーとは何か
「エラー = 想定外のことが起きたもの」という捉え方は解像度が低い。
より正確には、エラーとはルール違反の報告である。
ある処理には必ず「依拠しているルール」がある。値の形式ルール、業務上の前提条件、外部システムとの契約。エラーとは、その処理の実行中に「どのルールが、どう破られたか」を呼び出し側に能動的に伝えるための値だ。
この捉え方の差は実装に直結する。「想定外のもの」として扱うとエラーは例外的な存在になるが、「ルール違反の報告」として扱うと、正常系と同じく 必ず処理すべき分岐のひとつ になる。
関数のシグネチャを Input → Output ではなく Input → Output | Error と捉えると、エラーは戻り値の一形態として自然に設計対象になる。
「何が起きたか」と「どう返すか」を分ける
エラーハンドリングの混乱は、大抵 2つの責務を1つの概念に混ぜている ことから生まれる。
| 責務 | 問い | 知っているべき層 |
|---|---|---|
| エラーが起きた | 何のルールが破られたか | 各レイヤー(domain / usecase) |
| HTTPでどう返すか | どのステータスコードで返すか | プレゼンテーション層のみ |
HTTPException パターンはこの2つを1つの概念に混ぜてしまっている。ドメイン層で throw new HTTPException(409, ...) を書いた時点で、ドメイン層は「409 というステータスコードの存在」を知ってしまう。
きれいな設計はこうなる。
各レイヤー → 「○○エラーが起きた」という意味だけ伝える
変換層 → 「それをどうHTTPレスポンスに落とすか」を知っている
この変換の接点に アダプタが1つあるだけ という構造が理想だ。各レイヤーは HTTP を知らない。プレゼンテーション層だけが HTTP への翻訳責務を持つ。
設計の結論:4つの部品
この考え方を実装に落とすと、4つの部品で構成できる。
| 部品 | 位置 | 責務 |
|---|---|---|
| ① エラー定義 | 各レイヤーに co-located | 何のルールが破られたかを型で表現 |
| ② errorMap | src/errorMap.ts |
エラー種別 → HTTP ステータス / メッセージの対応表 |
| ③ onError | ルートエントリ | 変換の唯一の実行ポイント |
| ④ ルートハンドラ | 各エンドポイント | usecase を呼ぶだけ。try/catch を書かない |
以下、それぞれを順に見ていく。
① エラー定義(各レイヤーに co-located)
エラー型は、それを throw するレイヤーのすぐ隣に置く。domain の policy なら policy と同じディレクトリに、usecase 固有のエラーなら usecase と同じディレクトリに。
// domain/users/policies/assertNotRegistered/index.ts
import type { User } from "../../entities/User";
export type UserAlreadyRegisteredError = Error & {
readonly type: "UserAlreadyRegisteredError";
readonly subId: string;
};
export const createUserAlreadyRegisteredError = (
subId: string,
): UserAlreadyRegisteredError => {
const error = new Error(
`User already registered: ${subId}`,
) as UserAlreadyRegisteredError;
return Object.assign(error, {
type: "UserAlreadyRegisteredError" as const,
subId,
});
};
export const assertNotRegistered = (
userIfFound: User | null,
subId: string,
): void => {
if (userIfFound) throw createUserAlreadyRegisteredError(subId);
};
ポイントは3つ。
typeフィールドで種別を識別できる- コンテキスト情報(
subId)をエラー自身が持つ - HTTP ステータスコードは一切登場しない
このエラーは「ユーザー登録のルールが破られた」という意味だけを伝えている。どう返すかはここでは一切決めていない。
② errorMap(src 直下)
エラーと HTTP の対応表。アプリ全体で「どんなエラーが起きうるか」が一覧できる唯一の場所。
// src/errorMap.ts
import type { ContentfulStatusCode } from "hono/utils/http-status";
import type { UserAlreadyRegisteredError } from "./domain/users/policies/assertNotRegistered";
import type { AccountIdAlreadyTakenError } from "./domain/artists/errors";
type AppError = UserAlreadyRegisteredError | AccountIdAlreadyTakenError;
type ErrorMapping<SpecificError extends AppError> = {
status: ContentfulStatusCode;
message: (error: SpecificError) => string;
};
const errorMap: {
[ErrorType in AppError["type"]]: ErrorMapping<
Extract<AppError, { type: ErrorType }>
>;
} = {
UserAlreadyRegisteredError: {
status: 409,
message: () => "User already registered",
},
AccountIdAlreadyTakenError: {
status: 409,
message: (error) => `Account ID already taken: ${error.accountId}`,
},
};
const isAppError = (error: unknown): error is AppError => {
if (!(error instanceof Error)) return false;
const type = (error as { type?: unknown }).type;
return typeof type === "string" && type in errorMap;
};
export type ErrorResponse = {
body: { error: string };
status: ContentfulStatusCode;
};
export const resolveErrorResponse = (error: unknown): ErrorResponse => {
if (isAppError(error)) {
const mapping = errorMap[error.type];
return {
body: { error: mapping.message(error as never) },
status: mapping.status,
};
}
console.error("[Unhandled error]", error);
return {
body: { error: "Internal Server Error" },
status: 500,
};
};
AppError を union 型にしているのがポイントで、新しいエラーを union に追加すると TypeScript が errorMap の未実装キーを指摘してくれる。「エラーを追加したのに変換を書き忘れた」事故が型で防げる。
errorMap と isAppError は外には公開せず、公開するのは resolveErrorResponse という関数ひとつだけにしている。呼び出し側は「エラーを渡せば { body, status } が返ってくる」という形しか知らなくてよく、内部で対応表を引いているのか、条件分岐しているのかは隠蔽される。変換の実装詳細が外に漏れないので、内部構造(例えば errorMap の型の表現)を後で変更しても、呼び出し側には影響しない。
③ onError(ルートエントリ)
変換が実際に走る唯一の場所。throw されたエラーを resolveErrorResponse に渡して、返ってきた body と status をそのまま JSON レスポンスにする。
// app/api/[[...route]]/route.ts
import { Hono } from "hono";
import { handle } from "hono/vercel";
import { requireAuthMiddleware } from "../../../middlewares/auth0";
import { resolveErrorResponse } from "../../../errorMap";
import usersCreate from "./users/create";
import usersMe from "./users/me";
const app = new Hono()
.basePath("/api")
.use("*", requireAuthMiddleware)
.route("/users/me", usersMe)
.route("/users", usersCreate)
.onError((error, c) => {
const { body, status } = resolveErrorResponse(error);
return c.json(body, status);
});
export const GET = handle(app);
export const POST = handle(app);
onError の中身はたった2行。個別のエラー種別に対する if 文も、isAppError による分岐も、ここには登場しない。フレームワーク(Hono)との接着面だけがここに残っていて、変換ロジック本体は errorMap.ts 側に閉じている。
この構造のおかげで、もし将来プレゼンテーション層を Hono から別のものに差し替えることになっても、手を入れるのは onError の2行だけで済む。resolveErrorResponse はフレームワーク非依存な純粋関数なので、そのまま使い回せる。
④ ルートハンドラ
try/catch は書かない。usecase を呼んで結果を返すだけ。
// app/api/[[...route]]/users/create/index.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { auth0 } from "../../../../../infrastructure/auth0";
import { getContainer } from "../../../../../infrastructure/container";
import { createUserUseCase } from "../../../../../usecases/users/createUser";
export const requestSchema = z.object({
email: z.string().min(1),
accountId: z.string().min(1),
});
const app = new Hono().post(
"/",
zValidator("json", requestSchema, (result, c) => {
if (!result.success) {
return c.json(
{ error: "Invalid request", issues: result.error.issues },
400,
);
}
}),
async (c) => {
const body = c.req.valid("json");
const session = await auth0.getSession();
if (!session?.user) return c.json({ error: "Unauthorized" }, 401);
const { userRepository, artistRepository, txRunner } = getContainer();
// try/catch 不要。throw は onError が受け取る
const result = await createUserUseCase(
{
subId: session.user.sub,
email: body.email,
accountId: body.accountId,
},
{
userRepository,
artistRepository,
txRunner,
},
);
return c.json(
{
userId: result.userId,
artistId: result.artistId,
},
201,
);
},
);
export default app;
ルートハンドラが知っているのは「成功したら何を返すか」だけ。エラーのことは何も知らなくていい。
新しいエラーを追加するとき
手順はこれだけ。
1. エラーを投げるレイヤーを決める
2. co-located で type + factory (+ assert関数) を定義
3. errorMap.ts の AppError union に型を追加
→ TypeScript が errorMap の未実装キーを指摘する
4. errorMap に status と message を実装
5. usecase / policy から throw する
ルートハンドラも onError も触らない。 これが本設計の最大のメリットだ。
既存のエラーハンドリング設計では、エラーを追加するたびに try/catch を書き足したり、if (error instanceof XxxError) を増やしたりする必要があった。この設計ではその作業が消える。エラーの定義と対応表の更新だけで閉じる。
各エラーの責務まとめ
レイヤーごとに「どのエラーを持つか」も明確に分かれる。
| レイヤー | 扱うエラーの例 | HTTPステータス | errorMap に載せる |
|---|---|---|---|
| domain | ビジネスルール違反(重複登録、遷移不可) | 409 / 422 | ○ |
| usecase | 複合操作の前提条件違反 | 404 / 409 | ○ |
| infrastructure | DB 障害、外部 API 障害 | 500 | × (ログだけ) |
| zValidator | リクエストバリデーション失敗 | 400 | × (middleware 内で返す) |
| 認証ミドルウェア | 未認証 | 401 | × (middleware 内で返す) |
Infrastructure 層のエラーを errorMap に登録しないのは意図的で、「DB が応答しません」という内部事情をクライアントに伝える必要はなく、500 + ログが正解だからだ。
バリデーションや認証は、そもそも usecase に到達する前の関門として middleware で完結させる。errorMap に載るのは ビジネスルール違反 だけになる。
HTTPException パターンとの対比
従来パターンの問題点を、この設計でどう解消できるかを整理する。
| 観点 | HTTPException パターン | errorMap パターン |
|---|---|---|
| ドメイン層に HTTP の知識があるか | ある(ステータスコードが混在) | ない(ドメインは type しか持たない) |
| エラー追加のコスト | try/catch や instanceof を増やす | errorMap に1行追加するだけ(型が未実装を検出) |
| プレゼンテーション層の差し替え | ドメインごと書き直し | errorMap と onError だけ差し替え |
| エラー一覧の見通し | コード全体に散らばる | errorMap.ts に集約 |
| テストのしやすさ | HTTPException ごとモックが必要 | ドメインエラーを純粋な値として検査できる |
まとめ
この設計の本質は一言で言える。
使う側は「何をしたいか」だけ知っていればいい。「どう動くか」は知らなくていい。
各レイヤーは「このルールが破られた」とエラーを throw するだけでよく、HTTP の知識は一切不要。HTTP への変換は errorMap と onError の内部に閉じている。
HTTPException パターンが「エラーハンドリング設計の詳細を知らないと参加できない」状態だったのに対し、この設計は 「errors.ts から import して throw するだけで動く」 状態を実現する。
新しいエラーを追加するコストは低く、ドメイン層は HTTP を知らないまま保たれ、プレゼンテーション層の差し替えにも耐える。「エラーとはルール違反の報告である」という定義を出発点にすれば、この構造は自然に導かれる。