第15章:I/O境界を外へ(薄いアダプタ)🚪✨
〜 状態機械の“中身”をピュアにして、テストも改修もラクにするよ〜🧪💖
この章でできるようになること🎯✨
- 状態機械の 中心(状態遷移) を「計算だけ」にしてスッキリ保つ🧼
fetch/localStorage/ タイマー…みたいな I/O(外界) を「外側」に追い出す🏃♀️💨- “薄いアダプタ”を作って、外の都合に汚されない設計にする🛡️✨
- その結果、テストが爆速&安心になる🧪⚡
(この考え方は Ports & Adapters / Hexagonal Architecture と呼ばれる定番の分離だよ〜📦✨) (Alistair Cockburn)
1) そもそも I/O って何がイヤなの?😵💫💥
I/O(外部とのやりとり)はだいたいこう👇
- 遅い🐢(通信・ファイル・DB…)
- 失敗する😇(ネットワーク落ちた、権限ない、容量ない…)
- 予測できない🎲(タイミング・順序・リトライ・キャンセル)
- テストしづらい🧪❌(外部に依存するから)
例えば fetch はブラウザの標準APIだし、Node.js でも使えるけど(Node 18で実験的→Node 21で stable へ)、「外界」なのは変わらないよ〜🌍📡 (MDN Web Docs)
だから結論👇 ✅ 状態機械の中(中心)に I/O を入れない ✅ I/O は外(アダプタ)に寄せる これがこの章のテーマだよ〜🚪✨
2) “薄いアダプタ”ってなに?🧩✨

イメージはこんな感じ👇
[ UI / CLI / API ] ──(Event)──▶ [ 状態機械コア ] ──(Effect/Port)──▶ [ アダプタ ] ──▶ fetch / storage / timer
▲ │ │
└──────(Event)◀────────┘ └──▶ 外界の結果を Event にして戻す
-
状態機械コア:状態遷移だけ(ピュア✨)
-
アダプタ:I/Oする(薄く!)
-
やりとり:
- コア →「こういうI/Oしてね(命令)」
- アダプタ →「結果が出たよ(イベント)」をコアに返す📮
この「コアは外に依存しない」考え方が Ports & Adapters の超重要ポイントだよ🫶 (Alistair Cockburn)
3) 薄いアダプタの“3つの約束”📜✨
アダプタを薄く保つコツはこれだけ覚えればOK🙆♀️💕
-
ビジネス判断を入れない🙅♀️
- 「成功なら次はこう」みたいな判断はコアへ
-
変換だけする🔁
- APIのレスポンス → ドメインで使う形に整形、くらい
-
失敗もイベント化して返す🚨➡️📮
NETWORK_ERROR/TIMEOUTみたいに“結果”として返す
4) 例:フォーム送信(超ミニ版)📨✨
この章は「I/Oを外へ」が主役なので、機械は小さめでいくね☺️🌸
4-1. 型を用意する(State / Event / Context / Effect)🧠🧩
// ---- core/types.ts ----
export type State =
| { kind: "editing" }
| { kind: "submitting" }
| { kind: "success" }
| { kind: "error"; message: string };
export type Event =
| { type: "SUBMIT"; name: string; email: string }
| { type: "SUBMIT_OK" }
| { type: "SUBMIT_NG"; message: string }
| { type: "RESET" };
// 「I/Oしてね!」の命令(副作用の説明書)
export type Effect =
| { type: "POST_FORM"; payload: { name: string; email: string } };
4-2. 状態機械コア:I/Oしない(ピュア)🍰✨
ポイント:次状態 + Effectリスト を返すだけ🌟
// ---- core/reducer.ts ----
import type { State, Event, Effect } from "./types";
export type ReduceResult = { state: State; effects: Effect[] };
export function reduce(state: State, event: Event): ReduceResult {
switch (state.kind) {
case "editing": {
if (event.type === "SUBMIT") {
return {
state: { kind: "submitting" },
effects: [{ type: "POST_FORM", payload: { name: event.name, email: event.email } }],
};
}
return { state, effects: [] };
}
case "submitting": {
if (event.type === "SUBMIT_OK") return { state: { kind: "success" }, effects: [] };
if (event.type === "SUBMIT_NG") return { state: { kind: "error", message: event.message }, effects: [] };
return { state, effects: [] };
}
case "success":
case "error": {
if (event.type === "RESET") return { state: { kind: "editing" }, effects: [] };
return { state, effects: [] };
}
}
}
✅ ここには fetch が1ミリも出てこない!最高!🧼✨
→ テストがめっちゃ簡単になるよ🧪💕
5) 外側:薄いアダプタで I/O を実行する🧰📡
アダプタは Effect を受け取って実行して、結果を Event にして返すよ📮
// ---- adapters/runEffect.ts ----
import type { Effect, Event } from "../core/types";
export async function runEffect(effect: Effect): Promise<Event | null> {
switch (effect.type) {
case "POST_FORM": {
try {
// ブラウザなら fetch は標準APIだよ🌐✨ :contentReference[oaicite:3]{index=3}
const res = await fetch("/api/form", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(effect.payload),
});
if (!res.ok) {
// 変換だけ(判断は最小限)🔁
return { type: "SUBMIT_NG", message: `送信に失敗しました(HTTP ${res.status})` };
}
return { type: "SUBMIT_OK" };
} catch {
return { type: "SUBMIT_NG", message: "通信エラーです(ネットワークを確認してね)" };
}
}
}
}
※ Node.js 側で fetch を使う場合も、最近は標準になってきてるよ(Node 18で experimental、Node 21で stable)🧩✨ (Node.js)
6) “配線”する(アプリ層)🔌✨
状態機械の「中心」と「外側」をつなぐだけの層だよ〜☺️
// ---- app/machine.ts ----
import type { State, Event } from "../core/types";
import { reduce } from "../core/reducer";
import { runEffect } from "../adapters/runEffect";
export function createMachine(onState: (s: State) => void) {
let current: State = { kind: "editing" };
async function send(event: Event) {
const { state, effects } = reduce(current, event);
current = state;
onState(current);
// Effect を外で実行して、結果を Event として戻す📮✨
for (const eff of effects) {
const nextEvent = await runEffect(eff);
if (nextEvent) await send(nextEvent);
}
}
return { send, getState: () => current };
}
この形にしておくと…
- コアはずっとピュア🍰✨
- I/Oは外で自由に差し替え🧩
- テストは reduce を叩くだけ🧪⚡
7) フォルダ構成おすすめ🏠✨
src/
core/ # 状態機械の中心(pure)
types.ts
reducer.ts
adapters/ # I/O(thin)
runEffect.ts
app/ # 配線(wiring)
machine.ts
8) よくある事故💣→🧯
- ❌ アダプタに「次は success にするべき」みたいな判断が入る → ✅ 判断はコアへ戻す
- ❌ コアの中で
fetch/localStorageを呼び出す → ✅ Effect にして外へ - ❌ 失敗を握りつぶす(ログだけ出して終わり)
→ ✅ 失敗も Event にして戻す(
*_NG)
9) AI活用プロンプト例🤖💖(コピペOK)
- 「この状態機械コアに I/O が混ざってないかチェックして、混ざってたら分離案を出して」🕵️♀️✨
- 「Effect設計(type一覧)を提案して。最小セット→拡張セットの順で」🎛️
- 「runEffect が“薄いアダプタ”になってるかレビューして。ビジネス判断が混ざってたら指摘して」🧼
- 「reduce のテストケース(状態×イベント)を表にして、抜けを埋めて」🧪📋
10) 成果物:I/O境界メモ(テンプレ)📝✨
この章のゴールはこれを作ることだよ〜💕
### 状態機械コア(pure)
- 扱う State:
- 扱う Event:
- コアが返す Effect:
### I/O(外界)
- API: 何を呼ぶ?
- Storage: 何を保存?
- Timer: 何が必要?
- ログ/計測: 何を送る?
### アダプタ方針(薄く)
- 変換するだけのルール:
- エラーを Event 化するルール:
- コアに判断を残すもの:
ちょい最新メモ🆕✨(今どきTS事情)
- TypeScript の安定版は 5.9.3 が “Latest” として公開されてるよ📌 (GitHub)
- TypeScript 5.9 には
import deferみたいな「副作用を遅らせる」方向の機能も入ってきてる(上級者向けだけど、思想はこの章と相性◎)⏳✨ (TypeScript)
まとめ🎀✨
- I/O は外界!遅い!失敗する!テストがつらい!😵💫
- 状態機械コアは ピュアな計算に寄せる🍰
- I/O は 薄いアダプタへ🚪✨
- コア→Effect、アダプタ→Event で往復すると超キレイ📮💕
次の第16章では、この「副作用の渡し方」を 3パターン(Effectリスト / コールバック / DI) で比べて、「どれを選べば事故りにくいか」まで整理するよ〜🎛️💖