第34章 エラー設計②:外側のエラー(I/O失敗)😵💫
![hex_ts_study_034[(./picture/hex_ts_study_034_handling_i_o_errors_infra.png)
この章は「ファイル読めない」「ネット落ちた」みたいな 外側(I/O)の事故を、ヘキサゴナル的に“きれいに”扱えるようになる回だよ〜🧸✨ いきなり結論:中心(ドメイン/ユースケース)は静かに🧠、外側(Adapter)で受け止めて、必要な形に翻訳する🧩!
0. 2026-01-23時点の前提メモ(超ミニ)🗓️✨
- Node は v24 が Active LTS(安定して使うならここが軸)だよ〜 (Node.js)
- TypeScript は 5.9.x 系が安定版ライン(5.9 のアナウンスあり)で、6/7 は進行中って流れ (Microsoft for Developers)
- Node の
fetch()は undici で動く内蔵 fetch(Node 18〜) (Node.js) - 例外を包むなら
new Error(msg, { cause })の cause が超便利 (Node.js)
1. この章のゴール 🎯💖
この章が終わったら、こんなことができるようになるよ〜😊✨
- I/O失敗を 「中心の仕様エラー」と混ぜないで整理できる🧠🧼
- Adapter で起きた例外を アプリ用のエラー型に翻訳できる🧩
- ログに **状況(コンテキスト)**を残して、デバッグしやすくできる🪪📌
- 「リトライしていい失敗/ダメな失敗」を分けられる🔁✅
2. I/O失敗ってどんなやつ?あるある😵💫💥
ファイル系(FileRepository)📄💾
- ファイルが無い(初回起動とか)😳
- 権限がない(EACCES)🔒
- JSONが壊れてる(途中で手で編集しちゃった…)🫠
- 同時書き込みで壊れる(並列処理)💥
ネット系(外部API)🌐⚡
- 回線が落ちる / DNSが死ぬ📡💀
- タイムアウトする⌛
- 503(相手が死んでる)🧯
- 401/403(認証エラー)🔑
3. 超重要:中心のエラーと、外側のエラーは“別もの”🧠🧩
✅ 中心(仕様)のエラー(第33章の範囲)
- 「タイトル空はダメ」
- 「完了の二重適用はダメ」 → これは 仕様だから、中心が判断してOK🙆♀️
✅ 外側(運用/I/O)のエラー(第34章)
- 「ファイル読めない」
- 「ネット落ちた」 → これは 環境の事故だから、中心が抱えると世界が汚れる😱
なので、ヘキサゴナルのノリでこうするよ👇
- Adapterで受け止める🧤
- アプリが理解できる形に翻訳して返す🧩
- ログは外側で残す📝
- 中心は外部例外の種類を知らない🙅♀️
4. 外側エラー設計の型:おすすめの最小セット🧩✨
「アプリ的に意味がある」情報だけ持つのがコツだよ✂️💖
- 何が起きた?(kind)
- リトライしていい?(retryable)
- ユーザーに見せるメッセージ(publicMessage)
- デバッグ用の情報(cause / details)
例:InfraError(外側の翻訳結果)🧱
export type InfraErrorKind =
| "STORAGE_NOT_FOUND"
| "STORAGE_PERMISSION_DENIED"
| "STORAGE_CORRUPTED"
| "STORAGE_IO_FAILED"
| "NETWORK_TIMEOUT"
| "NETWORK_UNAVAILABLE"
| "REMOTE_BAD_STATUS";
export class InfraError extends Error {
constructor(
public readonly kind: InfraErrorKind,
message: string,
public readonly options: {
retryable: boolean;
publicMessage: string;
details?: Record<string, unknown>;
cause?: unknown;
}
) {
super(message, { cause: options.cause }); // causeで「根っこ」を保持✨
this.name = "InfraError";
}
get retryable() {
return this.options.retryable;
}
get publicMessage() {
return this.options.publicMessage;
}
get details() {
return this.options.details;
}
}
cause は **「翻訳前の元エラー」**を残すための公式ルートだよ🧠✨ (Node.js)
5. FileRepositoryでやってみる:例外→InfraErrorに翻訳🧩📄
ここでは fs/promises を使うよ(Node公式) (Node.js)
5.1 Nodeのエラーコードを安全に見る小ワザ🔍
type NodeErrnoException = Error & { code?: string };
function isNodeErrno(e: unknown): e is NodeErrnoException {
return e instanceof Error;
}
5.2 JSONロード(壊れてた/無い/権限ない…を分岐)📥😵💫
import fs from "node:fs/promises";
export class FileTodoRepositoryAdapter {
constructor(private readonly filePath: string) {}
async loadAll(): Promise<unknown[]> {
try {
const text = await fs.readFile(this.filePath, "utf-8");
try {
const parsed = JSON.parse(text);
if (!Array.isArray(parsed)) throw new Error("JSON is not an array");
return parsed;
} catch (cause) {
// JSON壊れてる系
throw new InfraError(
"STORAGE_CORRUPTED",
"Failed to parse storage JSON",
{
retryable: false,
publicMessage: "保存データが壊れているみたい…🥺(JSONの形式を確認してね)",
details: { filePath: this.filePath },
cause,
}
);
}
} catch (cause) {
if (isNodeErrno(cause)) {
if (cause.code === "ENOENT") {
// 初回起動:ファイル無しは「正常系」扱いにして空配列でもOK🙆♀️
return [];
}
if (cause.code === "EACCES" || cause.code === "EPERM") {
throw new InfraError(
"STORAGE_PERMISSION_DENIED",
"No permission to read storage file",
{
retryable: false,
publicMessage: "保存先にアクセス権がないみたい…🔒(フォルダ権限を見てね)",
details: { filePath: this.filePath, code: cause.code },
cause,
}
);
}
}
throw new InfraError("STORAGE_IO_FAILED", "Failed to read storage file", {
retryable: true, // 一時的なI/Oなら再試行の余地あり
publicMessage: "保存データの読み込みに失敗したよ…😵💫 もう一度試してね",
details: { filePath: this.filePath },
cause,
});
}
}
}
ポイント💡
ENOENT(無い)は「初回なら普通」なので、エラーにしない選択もアリ😊- JSON壊れは retryしても直らない →
retryable: false causeに元の例外を刺しておくと、あとでログで追える✨ (Node.js)
6. ネットワークAdapter:fetch失敗を翻訳🌐🧩
Nodeの fetch() は undiciベースの内蔵 fetch だよ (Node.js)
6.1 タイムアウトは AbortController で止める⌛🛑
export async function fetchWithTimeout(
url: string,
init: RequestInit,
timeoutMs: number
): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...init, signal: controller.signal });
} catch (cause) {
// Abort はタイムアウトの可能性が高い
throw new InfraError("NETWORK_TIMEOUT", "Request timed out", {
retryable: true,
publicMessage: "通信がタイムアウトしたよ…⌛ 電波いいとこで再試行してね",
details: { url, timeoutMs },
cause,
});
} finally {
clearTimeout(timer);
}
}
(この形は定番!) (Stack Overflow)
6.2 HTTPステータスは「通信成功だけど結果NG」🚦
export async function callRemoteApiExample(url: string): Promise<unknown> {
let res: Response;
try {
res = await fetchWithTimeout(url, { method: "GET" }, 5000);
} catch (cause) {
// ここはすでに InfraError が投げられてる想定(再スローでOK)
if (cause instanceof InfraError) throw cause;
throw new InfraError("NETWORK_UNAVAILABLE", "Network failure", {
retryable: true,
publicMessage: "ネットワークに問題があるみたい…📡 少し待って再試行してね",
details: { url },
cause,
});
}
if (!res.ok) {
throw new InfraError("REMOTE_BAD_STATUS", "Remote returned bad status", {
retryable: res.status >= 500, // 5xxだけ再試行候補、4xxは基本NG
publicMessage:
res.status >= 500
? "相手サーバーが混雑してるかも…😵💫 少し待って再試行してね"
: "リクエストが拒否されたよ…🔒(設定や認証を確認してね)",
details: { url, status: res.status },
});
}
return await res.json();
}
7. リトライ方針:「何でも再試行」はダメ⚠️🔁
- ✅ 再試行してOK:ネット一時不調、5xx、瞬断
- ❌ 再試行しても無駄:JSON壊れ、権限不足、4xx(大半)
一番ミニな指数バックオフ(雰囲気だけでもOK)👇
function sleep(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
export async function withRetry<T>(
fn: () => Promise<T>,
maxAttempts = 3
): Promise<T> {
let last: unknown;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (e) {
last = e;
// InfraErrorだけ判定する(それ以外は即死でもOK)
if (!(e instanceof InfraError) || !e.retryable || attempt === maxAttempts) {
throw e;
}
const backoff = 200 * Math.pow(2, attempt - 1);
await sleep(backoff);
}
}
throw last;
}
8. ログ:外側で「状況」を残す📝🪪
最低限でも **「どの操作」「どのファイル/URL」「どの失敗種別」**があると救われるよ〜🙏
8.1 まずは最小:consoleでもOK😊
function logInfraError(e: InfraError) {
console.error("[infra-error]", {
kind: e.kind,
message: e.message,
publicMessage: e.publicMessage,
details: e.details,
cause: e.cause, // Node/JSの標準機能✨
});
}
8.2 ちゃんと構造化するなら pino も定番(任意)🧰✨
pino は Node 向け JSON ロガーとして広く使われてるよ (getpino.io)
9. 入口(CLI/HTTP)では「表示用」に整形する🎀🧩
外側エラーは、ユーザーにそのまま生で見せないで、優しい文にするのが吉🥹💕
export function formatForUser(e: unknown): string {
if (e instanceof InfraError) return e.publicMessage;
if (e instanceof Error) return "予期しないエラーが起きたよ…🥺(詳細はログを見てね)";
return "よくわからない失敗が起きたよ…😵💫";
}
10. AIに頼るときの“安全プロンプト”🤖✅(そのままコピペOK)
- 「この Adapter の責務は“変換と呼び出しだけ”になってる?業務ルール混ざってない?」🧩🥗
- 「I/O失敗を、InfraError(kind/retryable/publicMessage/cause) に翻訳できてる?」🧱✨
- 「retryable の判定は妥当?4xx をリトライしてない?」🔁⚠️
- 「ログに filePath / url / kind が残る?」📝📌
11. 自主課題📝🎀
- 壊れたJSONをわざと作って起動 →
STORAGE_CORRUPTEDが出るか確認🫠 fetchWithTimeoutを使って、超遅いURLに当てて timeout を観測⌛withRetryを噛ませて、retryableだけ再試行されるか確認🔁✅
まとめ🎁💖
- 外側の失敗(I/O)は Adapterで受け止めて翻訳🧩
- 中心は仕様だけに集中して 静かに保つ🧠🛡️
causeを残すと、あとで絶対助かる✨ (Node.js)- リトライは「やっていい失敗」だけ!🔁⚠️
次の章(Composition Root)で、本番はFileRepo/テストはInMemoryみたいな切替を“気持ちよく”やるよ〜🧩🏗️✨