第12章:分離の基本手順(押し出す→薄くする)🧹➡️🧩

この章は「ぐちゃっとした最悪コード」から出発して、安全に I/O を外へ押し出して、境界を薄くして、最後にテストで守るところまでやるよ〜!🧪✨
(いまの主流ツール感としては、Node は Active LTS が 24 系、TypeScript は npm の最新が 5.9.3、テストは Vitest 4 系が人気どころだよ〜📌) (Node.js)
0. 今日のゴール🎯💖章が終わったら、これができるようになるよ👇
- 「最悪コード」を見て I/O とロジックの境目を言語化できる🗣️
- 中心(ロジック)を先に抽出できる🧠✨
- I/O を 外へ押し出して、境界を 薄く保つコツがわかる🧼
- 最後に ユニットテストで中心をガッチリ守れる🧪🛡️
1. 「押し出す→薄くする」ってなに?🧹🪄### 押し出す(Push out)
➡️* **I/O(外の世界)**を中心から追い出す!
- 例:
fetch/ ファイル /Date/process.env/console.log…ぜんぶ外側へ🚪
薄くする(Make thin)
🧩* 境界(アダプタ)は 変換して渡すだけにする
- “ビジネスルール” を境界に置かない(置くとテストが地獄😇)
合言葉: 「中心は計算と判断だけ」「外側は取得と保存だけ」☕✨
2. ハンズオン題材:最悪コードから救出しよ😵💫➡️
😆題材:注文合計を計算して、最後にログ保存する処理🛒🧾 (I/O がいっぱい混ざってる“ありがち地獄”をわざと作るよ🔥)
3. Step0:最悪コード(まずは現状を直視👀💦)
// src/checkout.ts
import { promises as fs } from "node:fs";
type Item = { id: string; qty: number };
export async function checkout(userId: string, items: Item[]) {
const discountRate = Number(process.env.DISCOUNT_RATE ?? "0"); // env(I/O)
const isWeekend = [0, 6].includes(new Date().getDay()); // Date(I/O)
// HTTP(I/O)
const ids = items.map((x) => x.id).join(",");
const res = await fetch(`https://example.com/api/prices?ids=${ids}`);
if (!res.ok) throw new Error("price api failed");
const prices = (await res.json()) as Record<string, number>;
// ロジック(中心っぽいの)がI/Oに埋もれてる…
let total = 0;
for (const item of items) {
total += (prices[item.id] ?? 0) * item.qty;
}
if (isWeekend) total *= 0.9; // 週末10%OFF
total *= 1 - discountRate; // envの割引
console.log("TOTAL", total); // log(I/O)
await fs.appendFile("orders.log", `${userId}\t${total}\n`, "utf-8"); // file(I/O)
return total;
}
どこがツライ?😇💥* Date と process.env があると テストが不安定になりがち⏰⚙️
fetchとfsがあると テストが遅い&壊れやすい🌐📁- しかも全部が1つに混ざってて、どこをテストすればいいか不明🫠
4. Step1:中心抽出(まずはロジックを“外に出す”🧠✨
)コツはこれ👇 **「コピペでいいから中心候補を別関数にして、あとで整える」**🧼
// src/core/pricing.ts
type Item = { id: string; qty: number };
export function calculateTotal(args: {
items: Item[];
prices: Record<string, number>;
discountRate: number;
isWeekend: boolean;
}) {
const { items, prices, discountRate, isWeekend } = args;
let total = 0;
for (const item of items) {
total += (prices[item.id] ?? 0) * item.qty;
}
if (isWeekend) total *= 0.9;
total *= 1 - discountRate;
// 小数の扱いは一旦そのまま(後で設計できるよ🪙)
return total;
}
そして元の関数は「呼ぶだけ」に近づける👇
// src/checkout.ts
import { promises as fs } from "node:fs";
import { calculateTotal } from "./core/pricing";
type Item = { id: string; qty: number };
export async function checkout(userId: string, items: Item[]) {
const discountRate = Number(process.env.DISCOUNT_RATE ?? "0");
const isWeekend = [0, 6].includes(new Date().getDay());
const ids = items.map((x) => x.id).join(",");
const res = await fetch(`https://example.com/api/prices?ids=${ids}`);
if (!res.ok) throw new Error("price api failed");
const prices = (await res.json()) as Record<string, number>;
const total = calculateTotal({ items, prices, discountRate, isWeekend });
console.log("TOTAL", total);
await fs.appendFile("orders.log", `${userId}\t${total}\n`, "utf-8");
return total;
}
✅ この時点での勝ち:中心が「I/Oなし」で独立した!🎉
5. Step2:I/Oを外へ押し出す(中心に“値だけ”渡す📦➡️
🧠)次は、中心に渡すものを **“I/Oの結果(値)だけ”**に揃えるよ✨
(中心は fetch も fs も Date も知らない世界へ…!)
ここでやるのは主に2つ👇
- I/Oの取得:外側でやる(価格取得、週末判定、割引率取得)
- I/Oの保存:外側でやる(ログ、ファイル保存)
この段階では interface まで急がなくてOK🙆♀️ まず “中心=純粋” を徹底しよ〜!
6. Step3:境界をinterface化(差し替え可能にする📜✨
)ここからが「薄くする」の本番だよ〜🧼🫶 中心が欲しいのは「HTTPの詳細」じゃなくて、**“価格が取れること”**だけ!
境界(最小の約束)
を定義📌
// src/boundary.ts
export type Item = { id: string; qty: number };
export interface PriceProvider {
getPrices(ids: string[]): Promise<Record<string, number>>;
}
export interface Clock {
now(): Date;
}
export interface OrderLog {
write(line: string): Promise<void>;
}
中心(ユースケース)
を作る🧠✨「やりたいこと」をまとめて、I/O は interface 越しに使う!
// src/usecase/checkoutUsecase.ts
import { calculateTotal } from "../core/pricing";
import type { Clock, Item, OrderLog, PriceProvider } from "../boundary";
export async function checkoutUsecase(args: {
userId: string;
items: Item[];
discountRate: number; // envの“結果”だけ渡す
priceProvider: PriceProvider;
clock: Clock;
orderLog: OrderLog;
}) {
const { userId, items, discountRate, priceProvider, clock, orderLog } = args;
const isWeekend = [0, 6].includes(clock.now().getDay());
const ids = items.map((x) => x.id);
const prices = await priceProvider.getPrices(ids);
const total = calculateTotal({ items, prices, discountRate, isWeekend });
await orderLog.write(`${userId}\t${total}`);
return total;
}
外側(実装=アダプタ)
は“薄く”🧩✨
// src/adapters/httpPriceProvider.ts
import type { PriceProvider } from "../boundary";
export class HttpPriceProvider implements PriceProvider {
async getPrices(ids: string[]) {
const res = await fetch(`https://example.com/api/prices?ids=${ids.join(",")}`);
if (!res.ok) throw new Error("price api failed");
return (await res.json()) as Record<string, number>;
}
}
// src/adapters/systemClock.ts
import type { Clock } from "../boundary";
export class SystemClock implements Clock {
now() {
return new Date();
}
}
// src/adapters/fileOrderLog.ts
import { promises as fs } from "node:fs";
import type { OrderLog } from "../boundary";
export class FileOrderLog implements OrderLog {
constructor(private path: string) {}
async write(line: string) {
await fs.appendFile(this.path, line + "\n", "utf-8");
}
}
薄い境界のチェック✅
- アダプタに 割引ルールとか入ってない?🙅♀️
- 変換して呼ぶだけ?(理想:ほぼ “配線”)🔌✨
7. Step4:テスト追加(中心を速く・確実に守る🧪🛡
️)### まず中心(純粋関数)
をテスト🍰✨Vitest は TypeScript でも導入が軽いよ〜 (Vitest)
// src/core/pricing.test.ts
import { describe, it, expect } from "vitest";
import { calculateTotal } from "./pricing";
describe("calculateTotal", () => {
it("平日は割引なし", () => {
const total = calculateTotal({
items: [{ id: "A", qty: 2 }],
prices: { A: 100 },
discountRate: 0,
isWeekend: false,
});
expect(total).toBe(200);
});
it("週末は10%OFF", () => {
const total = calculateTotal({
items: [{ id: "A", qty: 2 }],
prices: { A: 100 },
discountRate: 0,
isWeekend: true,
});
expect(total).toBe(180);
});
it("env割引も効く(例:20%)", () => {
const total = calculateTotal({
items: [{ id: "A", qty: 1 }],
prices: { A: 1000 },
discountRate: 0.2,
isWeekend: false,
});
expect(total).toBe(800);
});
});
次にユースケースをテスト(差し替えで爆速🧸💨
)
// src/usecase/checkoutUsecase.test.ts
import { describe, it, expect } from "vitest";
import { checkoutUsecase } from "./checkoutUsecase";
import type { Clock, OrderLog, PriceProvider } from "../boundary";
class FakeClock implements Clock {
constructor(private d: Date) {}
now() { return this.d; }
}
class FakePriceProvider implements PriceProvider {
constructor(private prices: Record<string, number>) {}
async getPrices() { return this.prices; }
}
class SpyOrderLog implements OrderLog {
lines: string[] = [];
async write(line: string) { this.lines.push(line); }
}
describe("checkoutUsecase", () => {
it("週末判定+価格取得+ログ出力がつながる", async () => {
const log = new SpyOrderLog();
const total = await checkoutUsecase({
userId: "u1",
items: [{ id: "A", qty: 2 }],
discountRate: 0.1,
priceProvider: new FakePriceProvider({ A: 100 }),
clock: new FakeClock(new Date("2026-01-17T12:00:00Z")), // 土曜
orderLog: log,
});
// 200 -> 週末10%OFFで180 -> env 10%OFFで162
expect(total).toBe(162);
expect(log.lines[0]).toContain("u1");
});
});
8. ちょい最新メモ:NodeはTypeScriptを“そのまま実行”もできるよ🧠⚡Node は v22.18.0 以降なら「消せる型」だけの TypeScript を フラグなしで実行できるよ(型を剥がして実行する感じ)
📌 (Node.js)
だから「動作確認だけならサクッと」もやりやすい〜!✨
(ただし 型チェックはしてくれないので、tsc やテストで守るのが大事🛡️)
9. よくある事故&回避術🚑💡* 境界が太る:アダプタに if/ルールが増え始めたら黄色信号🟡
→ ルールは中心へ!境界は変換だけ!
- interfaceがデカくなる:何でも入れると地獄💀 → “今回必要な操作だけ” にする(最小の約束📜)
- 一気に全部リファクタしたくなる:気持ちはわかる…!😂 → 小さく刻んで、1ステップごとに動作確認✅
10. 🤖 AI拡張に投げると強いプロンプト例(丸投げ禁止ね🫶)### ①中心抽出の下書き* 「この関数から I/O を除いた純粋関数 calculateTotal を抽出して。入力と出力の設計も提案して。」
②境界の最小化* 「PriceProvider を最小の interface にしたい。usecase が本当に必要なメソッドだけに絞って提案して。」
③テストケース洗い出し* 「週末割引・env割引・価格欠損・qty=0・小数の丸め、の観点でユニットテストケースを列挙して。」
AIの答えは “境界が薄いか?” で採点するとハズレにくいよ👀✨
11. まとめ(今日の型🧁🧪)* ✅ まず 中心を抽出(ロジックを見える化)
- ✅ 次に I/O を外へ押し出す(中心に値だけ渡す)
- ✅ さらに interface で境界を固定(差し替え可能に)
- ✅ 最後に 中心をテストで守る(速い・安定・安心)
12. 練習問題(ちょい宿題🎒✨
)1. discountRate の扱いを「0〜1の範囲に丸める」ルールにしたい!
→ そのルールはどこに置く?中心?境界?理由もセットで✍️😊
-
価格取得 API のレスポンスが
{ prices: {...} }に変わった! → どのファイルの変更で済ませるのが理想?🧩✨ -
“ログは保存失敗しても注文自体は成功扱い”にしたい! → 境界で握りつぶす?中心で扱う?どう設計する?🤔
次の章(第13章)は、この流れをさらに気持ちよくする **「引数注入(依存を外から渡す)」**に入っていくよ〜🎁➡️✨