第34章:エラー変換(内側→外側の表現)⚠️➡️🌐
この章はね、「ドメイン(内側)の失敗」を HTTP(外側)の失敗 に“翻訳”して、いつも同じ形で返せるようにする回だよ😊✨ クリーンアーキでめちゃ大事な「境界で変換する」を、エラーで体に入れよう〜!💪💖
1) まず最重要:失敗は“2種類”に分ける🧠✨

同じエラーでも、意味が違うから分けるよ〜!
A. 期待される失敗(ドメイン失敗)📌
- 例:タイトルが空、すでに完了済み、存在しないTaskを完了しようとした…など
- 仕様として起こり得るから、ちゃんと「内側の言葉」で表現してOK👌
B. 期待されない失敗(技術失敗・想定外)💥
- 例:DB落ちた、ネットワーク壊れた、コードのバグで例外…など
- 外側では安全な情報だけ返す(詳細はログへ)🔒
HTTPのステータスコード自体の意味は、HTTP仕様(RFC 9110)で整理されてるよ〜📚(RFCエディタ)
2) 外側の“標準の形”を決める:Problem Details(RFC 9457)🧾✨
ここで最新寄りの鉄板が RFC 9457 “Problem Details for HTTP APIs” だよ! これ、昔よく使われたRFC 7807を置き換える(obsoletes)仕様ね📌(RFCエディタ)
返すJSONの基本フィールドはこんな感じ(代表)👇
type:問題タイプ(URI推奨)title:短い人間向け説明status:HTTPステータスdetail:もう少し詳しくinstance:この発生事例の識別子(トレースIDなどに使いやすい)
メディアタイプは application/problem+json を使うのがポイントだよ🧾✨(Swagger)
3) 変換テーブルを作ろう(これが“境界の翻訳辞書”)📚➡️🌐
ミニTaskアプリなら、内側のエラーは例えばこんな想定になるよね😊
InvalidTitle(空・長すぎ等)TaskNotFoundAlreadyCompleted
これをHTTPに翻訳する例👇(方針の例だよ〜)
InvalidTitle→ 400 or 422(入力は正しいけど意味がダメなら422寄り…ただ運用は統一が大事!)TaskNotFound→ 404AlreadyCompleted→ 409(状態の衝突=Conflict)
HTTPステータスの一覧や意味はMDNが最新更新されてて見やすいよ📚✨(MDNウェブドキュメント)
4) 実装:内側のエラー(DomainError)を“判別しやすい形”で持つ🧩
ここ、TypeScriptの得意技🔥 **判別可能なUnion(discriminated union)**で作ると、翻訳が超ラクになるよ😊✨
// entities or usecases 側(内側の言葉)
export type DomainError =
| { type: "InvalidTitle"; reason: "empty" | "tooLong"; max?: number }
| { type: "TaskNotFound"; id: string }
| { type: "AlreadyCompleted"; id: string };
ポイント💡
typeを固定文字列にしておくとswitchが気持ちよく書ける🫶reasonみたいな補助情報は 内側の事情として持ってOK(外側に出すかは後で決める)✨
5) 実装:Problem Detailsの型(外側の標準形)🧾
export type ProblemDetails = {
type: string;
title: string;
status: number;
detail?: string;
instance?: string;
// RFC 9457 は拡張メンバーOK(必要なら足してよい)
[key: string]: unknown;
};
拡張メンバー(例えば errors とか code)を足せるのが便利〜!🧁
RFC 9457 は「新しい独自フォーマット乱立」を避けたい思想があるよ📌(RFCエディタ)
6) 実装:翻訳関数(DomainError → HTTP + ProblemDetails)🔁✨
ここがこの章の“主役”!🎬
import { DomainError } from "../usecases/DomainError";
import { ProblemDetails } from "./ProblemDetails";
export function toProblemDetails(
err: DomainError,
instance?: string
): { status: number; body: ProblemDetails } {
switch (err.type) {
case "InvalidTitle": {
// ここは方針で 400/422 どちらでも。チームで統一が大事😊
const status = 400;
return {
status,
body: {
type: "https://example.com/problems/invalid-title",
title: "Invalid title",
status,
detail:
err.reason === "empty"
? "title must not be empty"
: `title is too long${err.max ? ` (max ${err.max})` : ""}`,
instance,
code: "INVALID_TITLE", // 拡張メンバー例
},
};
}
case "TaskNotFound": {
const status = 404;
return {
status,
body: {
type: "https://example.com/problems/task-not-found",
title: "Task not found",
status,
detail: `task id=${err.id} was not found`,
instance,
code: "TASK_NOT_FOUND",
},
};
}
case "AlreadyCompleted": {
const status = 409;
return {
status,
body: {
type: "https://example.com/problems/already-completed",
title: "Already completed",
status,
detail: `task id=${err.id} is already completed`,
instance,
code: "ALREADY_COMPLETED",
},
};
}
}
}
ここでのコツ💡
typeは “変更しにくい識別子” にする(クライアントが分岐に使える)🧠✨detailは ユーザーに見せても安全な範囲に限定🔒instanceはログのトレースIDと繋げると最強🧵✨
7) Controller(Inbound Adapter)で使う:境界で変換する🚪➡️🧾
UseCaseが Result 形式で返してくる想定(throwでもいいけど、この形は学習しやすい😊)
type Ok<T> = { ok: true; value: T };
type Err<E> = { ok: false; error: E };
type Result<T, E> = Ok<T> | Err<E>;
そしてController側👇
import { toProblemDetails } from "../adapters/toProblemDetails";
import { DomainError } from "../usecases/DomainError";
export async function createTaskController(req: any, res: any) {
const instance = crypto.randomUUID(); // 例:トレースID
const result: Result<{ id: string }, DomainError> = await req.usecase.execute({
title: req.body?.title,
});
if (result.ok) {
return res.status(201).json(result.value);
}
const pd = toProblemDetails(result.error, instance);
return res
.status(pd.status)
.type("application/problem+json")
.json(pd.body);
}
これで「内側の言葉」を、外側の標準に“翻訳”できた〜!🎉💕
8) 想定外エラー(技術失敗)はどう返す?🧯💥
基本はこう👇
- クライアントには 500(または一時障害なら503)
- detailにスタックトレースとか出さない(セキュリティ😇🔒)
- 代わりに
instanceを返して「お問い合わせ時にこれ教えてね」方式にする
HTTPのステータスコードの意味を守るのが大事だよ〜📚(RFCエディタ)
9) エラーの“原因”を残す:Error.cause が便利🧠🧵
境界をまたぐ時、原因を握りつぶすとデバッグ地獄😇
JS/TSの Error.cause が超使えるよ!(MDNで仕様&サポート状況まとまってる)📚✨(MDNウェブドキュメント)
try {
await repo.save(task);
} catch (e) {
throw new Error("failed to save task", { cause: e });
}
ログ側で cause を辿れると、原因調査がめっちゃ楽になるよ〜🕵️♀️✨(MDNウェブドキュメント)
10) テスト:翻訳関数は“超ユニットテスト向き”🧪💖
import { toProblemDetails } from "./toProblemDetails";
test("TaskNotFound -> 404 problem+json", () => {
const r = toProblemDetails({ type: "TaskNotFound", id: "t1" }, "trace-1");
expect(r.status).toBe(404);
expect(r.body.status).toBe(404);
expect(r.body.type).toContain("task-not-found");
expect(r.body.instance).toBe("trace-1");
});
ここが通ると「仕様としての翻訳」が壊れないから安心〜!🥰🧪
11) よくある落とし穴(ここ注意!)⚠️😵💫
- DomainErrorなのに500で返してしまう → クライアント側が「直せるエラー」か判断できない🥲
- type/title/detail が毎回バラバラ → UIがつらい、ドキュメントも死ぬ📚💀
- detailに内部情報(SQLやスタック)を入れる → セキュリティ事故の元😇🔒
- 400/422/409 の使い分けが人によって違う → “チーム方針として固定”が正義💪✨
12) この章の提出物(成果物)📦✨
- ✅
DomainError(判別可能Union) - ✅
ProblemDetails型 - ✅
toProblemDetails()変換関数 - ✅ Controllerで
application/problem+jsonを返す処理 - ✅ 変換テスト1〜3本🧪
13) 理解チェック問題(1問)📝🤔
「AlreadyCompleted を 400 で返しているAPIがあったとして、なぜ 409 の方が“自然”になりやすいの?(理由を1つ)」💡
14) AI相棒プロンプト(コピペ用)🤖✨
DomainError(typeで判別できるunion)を前提に、
RFC 9457 Problem Details (application/problem+json) 形式へ変換する
toProblemDetails関数をTypeScriptで書いて。
- InvalidTitle / TaskNotFound / AlreadyCompleted を扱う
- status, type, title, detail, instance を含める
- detailは内部情報を含めない
- 変換テーブルも先に提案して
必要なら次で、**「400/422をどう統一するか」**の“教材向けの決め方テンプレ”も作るよ😊✨(迷いがちな所だから、そこを型にしちゃおう!🧁💖)