メインコンテンツまでスキップ

第6章:合成の第一歩🧩「小さな責務」に分けるコツ✂️✨

この章はね、「合成(Composition)」に入るための超・大事な準備運動だよ〜!🏃‍♀️💨 いきなりStrategyとかDecoratorとかに行く前に、まずは “分けられる” って力を付けるのが最強です💪✨


6.0 この章でできるようになること🎯✨

  • 「この関数、でかすぎ…😇」を 分ける判断ができるようになる
  • 「検証✅」「計算🧮」「保存💾」みたいに、役割ごとに切り分けられる
  • 分けた部品を あとで合成しやすい形にできる(=継承より強い🔥)

6.1 「責務」ってなに?🤔🧠

「責務」って、むずかしい言葉に見えるけど、超ざっくり言うと👇

そのコードが“何のために存在してるか” そして、何が起きたら変更されるか(=変更理由)

たとえば createOrder() がこんなこと全部やってたら…😵‍💫

  • 入力のチェック(検証✅)
  • 価格計算(計算🧮)
  • DB保存(保存💾)
  • メール送信(通知📧)
  • ログ出力(監視📝)

これ、変更理由がバラバラだよね?

  • 「バリデーション仕様が変わった」
  • 「割引ルールが変わった」
  • 「DBが変わった」
  • 「通知方法が変わった」

👉 変更理由が多い=責務が混ざってるサインだよ🚨


6.2 分割のコツ3つ🍀(この3つだけ覚えてOK!)

コツ①:名前に and(〜と〜)が入ってたら分割候補✂️🧩

例:

  • validateAndSaveUser()
  • calculateAndPersistInvoice()
  • createOrderAndSendEmail()

「やること2つ書いてある」= 責務2つ以上の可能性大!😇


コツ②:if が増えてきたら分割候補🌳💥

if が増える理由ってだいたい👇

  • ルールが増えた(例:会員/非会員、国内/海外、クーポン種類…)
  • 例外パターンが増えた(例:在庫なし、上限超え…)

最初は小さくても、放置すると 分岐ジャングルになります🌴🧟‍♀️ 👉 「分岐の塊」は、Strategyとかに進化させやすい✨(7章につながるよ!)


コツ③:テストが書きにくかったら分割候補🧪😵

テストしづらい理由の代表👇

  • DBや外部APIに触ってる(遅い・不安定)
  • 時間や乱数に依存してる(毎回結果が違う)
  • 1回のテストで確認したいことが多すぎる

👉 テストしづらい=責務が混ざってるが多いよ〜!

(ちなみに、TypeScript自体の最新版は 5.9.3 が “latest” として公開されてるよ📦✨) (npm) (テストフレームワークは Vitest 4 系が主流に進んでる流れだよ🧪✨) (vitest.dev)


6.3 ミニ演習✍️:でかい関数を「検証」「計算」「保存」に割ろう✅🧮💾

ここからが本番!🔥 まずは「ありがちな地獄コード」からスタートします😇


6.3.1 まずは “分ける前” のコード(でかい関数)💥

// order.ts
type Item = { sku: string; price: number; qty: number };
type CreateOrderInput = {
userId: string;
items: Item[];
couponCode?: string;
};

type Order = {
id: string;
userId: string;
items: Item[];
subtotal: number;
discount: number;
total: number;
createdAt: Date;
};

export async function createOrder(input: CreateOrderInput): Promise<Order> {
// ① 検証(バリデーション)
if (!input.userId) throw new Error("userId is required");
if (!input.items || input.items.length === 0) throw new Error("items is required");
for (const item of input.items) {
if (!item.sku) throw new Error("sku is required");
if (item.price <= 0) throw new Error("price must be > 0");
if (item.qty <= 0) throw new Error("qty must be > 0");
}

// ② 計算(価格・割引)
const subtotal = input.items.reduce((sum, x) => sum + x.price * x.qty, 0);

let discount = 0;
if (input.couponCode) {
if (input.couponCode === "WELCOME10") discount = Math.floor(subtotal * 0.1);
else if (input.couponCode === "VIP20") discount = Math.floor(subtotal * 0.2);
else throw new Error("invalid coupon");
}

const total = subtotal - discount;

// ③ 保存(DBのつもり)
// 本当はDBだけど、ここでは擬似的に保存したことにする
const order: Order = {
id: "ord_" + Math.random().toString(16).slice(2),
userId: input.userId,
items: input.items,
subtotal,
discount,
total,
createdAt: new Date(),
};

// await db.orders.insert(order) 的なことをしたい気持ち
return order;
}

うん、ありがち!😂 でもこれ、将来つらいポイントがいっぱい👇

  • クーポン増えたら if/else が地獄👻
  • 保存先が変わったら、計算まで触りがち😇
  • 検証だけテストしたいのに、全部動かす羽目になる🧪💦

6.4 ステップ1:まず「検証✅」を外に出す✂️✨

「検証」は変更理由が独立してることが多いよね。 だから最初に抜くと効果がデカい!💪

// validator.ts
import type { CreateOrderInput } from "./types";

export function validateCreateOrderInput(input: CreateOrderInput): void {
if (!input.userId) throw new Error("userId is required");
if (!input.items || input.items.length === 0) throw new Error("items is required");

for (const item of input.items) {
if (!item.sku) throw new Error("sku is required");
if (item.price <= 0) throw new Error("price must be > 0");
if (item.qty <= 0) throw new Error("qty must be > 0");
}
}

6.5 ステップ2:「計算🧮」を外に出す✂️✨

計算も、仕様変更が入りやすいゾーン! (割引、税、送料…ぜったい増える😂)

// pricing.ts
import type { CreateOrderInput } from "./types";

export type PricingResult = {
subtotal: number;
discount: number;
total: number;
};

export function calculatePricing(input: CreateOrderInput): PricingResult {
const subtotal = input.items.reduce((sum, x) => sum + x.price * x.qty, 0);

let discount = 0;
if (input.couponCode) {
if (input.couponCode === "WELCOME10") discount = Math.floor(subtotal * 0.1);
else if (input.couponCode === "VIP20") discount = Math.floor(subtotal * 0.2);
else throw new Error("invalid coupon");
}

return {
subtotal,
discount,
total: subtotal - discount,
};
}

6.6 ステップ3:「保存💾」を外に出す(Repositoryにする)📦✨

保存は、DB・API・ファイル…変わりやすい! なので “保存専用の部品” にしちゃおう🧩

// repository.ts
import type { Order } from "./types";

export interface OrderRepository {
save(order: Order): Promise<void>;
}

// いまはメモリ保存(あとでDB版に差し替えやすい✨)
export class InMemoryOrderRepository implements OrderRepository {
private readonly orders: Order[] = [];

async save(order: Order): Promise<void> {
this.orders.push(order);
}

// デバッグ用(本番ではなくてもOK)
list(): Order[] {
return [...this.orders];
}
}

6.7 最後に「合成」する🧩✨(部品を組み立ててサービスにする)

ここが 合成優先の気持ちよさポイント!🥳 分けた部品を “持って” 使うだけ!

// types.ts
export type Item = { sku: string; price: number; qty: number };

export type CreateOrderInput = {
userId: string;
items: Item[];
couponCode?: string;
};

export type Order = {
id: string;
userId: string;
items: Item[];
subtotal: number;
discount: number;
total: number;
createdAt: Date;
};
// orderService.ts
import type { CreateOrderInput, Order } from "./types";
import { validateCreateOrderInput } from "./validator";
import { calculatePricing } from "./pricing";
import type { OrderRepository } from "./repository";

export class OrderService {
constructor(private readonly repo: OrderRepository) {}

async create(input: CreateOrderInput): Promise<Order> {
// ✅検証
validateCreateOrderInput(input);

// 🧮計算
const pricing = calculatePricing(input);

// 💾保存
const order: Order = {
id: "ord_" + Math.random().toString(16).slice(2),
userId: input.userId,
items: input.items,
subtotal: pricing.subtotal,
discount: pricing.discount,
total: pricing.total,
createdAt: new Date(),
};

await this.repo.save(order);
return order;
}
}

🎉できた! これでもう、将来こういうことが超やりやすい👇

  • 「保存だけDBにしたい」→ Repository差し替え🔁
  • 「クーポン増やしたい」→ pricing だけ触る🧮
  • 「検証ルール変えたい」→ validator だけ触る✅

6.8 テストが一気にラクになる🧪✨(ここがご褒美!)

分けると、テストが “細く” 書ける!😆 たとえば pricing だけテストしたいなら👇

// pricing.test.ts(例:Vitest)
import { describe, it, expect } from "vitest";
import { calculatePricing } from "./pricing";

describe("calculatePricing", () => {
it("WELCOME10で10%割引になる", () => {
const result = calculatePricing({
userId: "u1",
items: [{ sku: "A", price: 1000, qty: 2 }],
couponCode: "WELCOME10",
});

expect(result.subtotal).toBe(2000);
expect(result.discount).toBe(200);
expect(result.total).toBe(1800);
});
});

(Vitest は4系が大きく進んでいて、現代TS開発での採用が増えてるよ🧪✨) (vitest.dev)


6.9 よくある失敗あるある😇🕳️(回避しよ!)

  • 分けすぎて迷子:ファイルが増えすぎて「どこ?」ってなる 👉 まずはこの章みたいに **3分割(検証/計算/保存)**で十分◎
  • “万能utils”に突っ込むutils.ts がブラックホール化🕳️ 👉 validator.ts / pricing.ts みたいに 責務名で分けるのが正義✨
  • データ構造がグチャる:あちこちで { subtotal, discount... } がバラバラ 👉 PricingResult みたいに型を作って揃える🧩

6.10 AI拡張の使いどころ🤖✨(丸投げじゃなく“分割支援”に!)

おすすめプロンプト例🗣️💡

  • 「この関数を 検証 / 計算 / 保存 に分割して。ファイル分割案も出して」
  • 「責務が混ざってる箇所を指摘して、変更理由ごとに切って」
  • 「テストが書きやすい形にしたい。外部依存を分けて」

AIの出力をチェックする観点👀✅

  • 分けたあと、それぞれの役割が1行で説明できる?
  • 変更が入ったとき、触るファイルが最小になった?
  • 余計な抽象(interface乱立)になってない?😇

章末ミニ問題🎮✨

Q1️⃣:分割候補はどれ?

  • A. parseUserName()
  • B. validateAndSaveUser()
  • C. getUser()

👉 答え:B(and が入ってる=2責務っぽい✂️)

Q2️⃣:「ifが増えた」って、次の章(Strategy)でどう活きそう?

👉 ヒント:“やり方だけ差し替える” に進化できるよ🔁✨


まとめ🧩✨

この章の結論はこれ👇

  • 責務=変更理由のまとまり
  • 「and」「if増殖」「テストしづらい」は分割サイン🚨
  • まず 検証✅ / 計算🧮 / 保存💾 の3分割で勝てる🏆
  • 分けたら、合成(部品を持って組み立てる)が自然にできる🧩✨

次の章(第7章)は、今日の「if増殖」を救う Strategy(戦略) に行けるよ〜!🚚🔁💖