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

第15章:OCP実戦(料金計算を拡張できる形へ)💰🧾✨

この章では「割引が増えても、料金計算の本体をほぼ触らずに追加できる」形にしていくよ〜!😊🎟️ 今のTypeScriptは 5.9 が最新ラインとして整理されてるので、型機能も安心して使ってOKだよ✨ (TypeScript) (ついでにNode.jsは LTS(長期サポート) を選ぶのが安全運用の王道だよ〜🛡️)(Node.js) テストは Vitest 4系 が現行メジャーとして使いやすいよ🧪⚡ (vitest.dev)

Lego Tower


今日のゴール🎯✨

  • 割引が増えても if/switch地獄 にならない💥🙅‍♀️
  • 「割引の追加」は 新しいクラス(または関数)を足すだけ に寄せる🌱
  • 料金計算の本体(コア)は 変更しにくく、拡張だけしやすくする🔁🧠

まず“地獄の未来”を見よう👀💦(ダメ実装)

「割引タイプでswitchして計算」って最初はラクなんだけど… 割引が増えるほど 毎回ここを編集 することになるよね😵‍💫

type DiscountType = "NONE" | "STUDENT" | "RAINY" | "SET";

export function calcTotalBad(subtotalYen: number, discount: DiscountType): number {
switch (discount) {
case "STUDENT":
return Math.max(0, subtotalYen - 300); // 学割300円引き
case "RAINY":
return Math.floor(subtotalYen * 0.9); // 雨の日10%OFF
case "SET":
return Math.max(0, subtotalYen - 200); // セット割200円引き(仮)
case "NONE":
default:
return subtotalYen;
}
}

これの困りごと😢🌀

  • 割引が増えるたび calcTotalBadを編集(=既存の重要コードを触る)✋💥
  • 修正でバグると 全注文が壊れる(影響範囲デカすぎ)😱
  • “割引ロジック”が増えるほど テストも読みづらい 🧫📉

👉 ここをOCPで直していくよ〜!💪✨


OCPの考え方(超ざっくり)🚪✨

**「拡張はOK!でも“既存の重要なところ”はなるべく触らないでね」**って感じ😊 だから「変更されやすいところ(割引)」を 差し替え口 に分離するよ🎯🔁


料金計算の“設計方針”を決める🧭✨

今回の軸はこれ👇

  • 変わりやすい:割引ルール(学割・雨の日割・セット割…増える)🎟️☔🍱
  • 変わりにくい:合計計算の流れ(小計→割引適用→合計)🧾

なので…

✅ 合計計算は ルールの配列を順番に適用するだけ にする ✅ 割引は ルールとして外に増やせる ようにする


1) ドメインモデル(最小セット)を用意📦✨

「値段は小数にしない」でいくよ(浮動小数の誤差こわい😇) 日本円は整数で扱うのが楽ちん💴

export type Yen = number;

export type Item = {
sku: string;
name: string;
unitPriceYen: Yen;
quantity: number;
};

export type PricingContext = {
items: Item[];
isStudent: boolean;
isRainyDay: boolean;
};

小計計算も用意〜🧮✨

import type { PricingContext, Yen } from "./types";

export function calcSubtotalYen(ctx: PricingContext): Yen {
return ctx.items.reduce((sum, item) => sum + item.unitPriceYen * item.quantity, 0);
}

2) “拡張ポイント”=PricingRule を作る🧩🔁

ここが超大事!✨ 「割引ルールはこの形で追加してね」っていう 約束(interface) を作るよ😊

import type { PricingContext, Yen } from "./types";

export interface PricingRule {
readonly name: string;
apply(currentTotalYen: Yen, ctx: PricingContext): Yen;
}

3) 料金計算のコア(ここは“閉じる”)🛡️✨

PricingRuleの配列を reduceで順番に適用 するだけにするよ〜!

import type { PricingContext, Yen } from "./types";
import { calcSubtotalYen } from "./subtotal";
import type { PricingRule } from "./rule";

export class PriceCalculator {
constructor(private readonly rules: readonly PricingRule[]) {}

calcTotal(ctx: PricingContext): Yen {
const subtotal = calcSubtotalYen(ctx);

const total = this.rules.reduce((current, rule) => {
return rule.apply(current, ctx);
}, subtotal);

return Math.max(0, total); // 念のためマイナス禁止
}
}

✅ ここがポイント:割引が増えても PriceCalculator は変更しない 🎉 (“ルールを追加する”だけで拡張できる!)


4) 割引ルールを“追加するだけ”で増やす🎟️✨

学割(固定300円引き)👩‍🎓💖

import type { PricingContext, Yen } from "./types";
import type { PricingRule } from "./rule";

export class StudentDiscountRule implements PricingRule {
readonly name = "StudentDiscount";

apply(currentTotalYen: Yen, ctx: PricingContext): Yen {
if (!ctx.isStudent) return currentTotalYen;
return Math.max(0, currentTotalYen - 300);
}
}

雨の日(10%OFF)☔✨

import type { PricingContext, Yen } from "./types";
import type { PricingRule } from "./rule";

export class RainyDayDiscountRule implements PricingRule {
readonly name = "RainyDayDiscount";

apply(currentTotalYen: Yen, ctx: PricingContext): Yen {
if (!ctx.isRainyDay) return currentTotalYen;
return Math.floor(currentTotalYen * 0.9);
}
}

セット割(例:ドリンク+サンドの組み合わせで200円引き)🥪🥤🎉

「組み合わせ割引」みたいな ちょい複雑 も、ルール側に閉じ込められるのが嬉しいところ!

import type { PricingContext, Yen } from "./types";
import type { PricingRule } from "./rule";

function countSku(ctx: PricingContext, sku: string): number {
return ctx.items
.filter(i => i.sku === sku)
.reduce((sum, i) => sum + i.quantity, 0);
}

export class SetDiscountRule implements PricingRule {
readonly name = "SetDiscount";

// 例:COFFEE と SAND が1セットで200円引き
apply(currentTotalYen: Yen, ctx: PricingContext): Yen {
const coffee = countSku(ctx, "COFFEE");
const sand = countSku(ctx, "SAND");
const sets = Math.min(coffee, sand);
const discount = sets * 200;

return Math.max(0, currentTotalYen - discount);
}
}

5) “組み立て”だけで拡張する(Composition Root)🧩✨

最後に「使うルール一覧」を作って注入するだけ!

import { PriceCalculator } from "./price-calculator";
import { StudentDiscountRule } from "./rule-student";
import { RainyDayDiscountRule } from "./rule-rainy";
import { SetDiscountRule } from "./rule-set";
import type { PricingContext } from "./types";

const calculator = new PriceCalculator([
new StudentDiscountRule(),
new RainyDayDiscountRule(),
new SetDiscountRule(),
]);

const ctx: PricingContext = {
items: [
{ sku: "COFFEE", name: "コーヒー", unitPriceYen: 450, quantity: 1 },
{ sku: "SAND", name: "サンド", unitPriceYen: 650, quantity: 1 },
],
isStudent: true,
isRainyDay: false,
};

console.log(calculator.calcTotal(ctx));

✅ 割引を追加したい? → 新しいRuleを1個作って、配列に足すだけ 🎉🎉


6) テストで「拡張しても壊れない」を確保🧪✅

Vitest 4系でサクッといくよ〜!⚡ (vitest.dev)

ルール単体テスト

import { describe, it, expect } from "vitest";
import { StudentDiscountRule } from "../src/rule-student";

describe("StudentDiscountRule", () => {
it("学生なら300円引き", () => {
const rule = new StudentDiscountRule();
const total = rule.apply(1000, { items: [], isStudent: true, isRainyDay: false });
expect(total).toBe(700);
});

it("学生じゃないなら変化なし", () => {
const rule = new StudentDiscountRule();
const total = rule.apply(1000, { items: [], isStudent: false, isRainyDay: false });
expect(total).toBe(1000);
});
});

合成(パイプライン)テスト

import { describe, it, expect } from "vitest";
import { PriceCalculator } from "../src/price-calculator";
import { StudentDiscountRule } from "../src/rule-student";
import { RainyDayDiscountRule } from "../src/rule-rainy";

describe("PriceCalculator", () => {
it("小計→学割→雨割の順で適用される", () => {
const calc = new PriceCalculator([new StudentDiscountRule(), new RainyDayDiscountRule()]);
const total = calc.calcTotal({
items: [{ sku: "COFFEE", name: "コーヒー", unitPriceYen: 1000, quantity: 1 }],
isStudent: true,
isRainyDay: true,
});

// 小計1000 → 学割で700 → 雨割10%OFFで630
expect(total).toBe(630);
});
});

💡「ルールの順番で結果が変わる」ことがあるから、順番を仕様として固定するか、ルールを“順序なし設計”にするかは設計判断だよ〜🤔✨ (初心者段階では「順番固定」で全然OK!)


7) OCPチェックリスト✅🧠

新しい割引(例:誕生日割 🎂、初回注文割 🆕、会員ランク割 👑)を追加するとき…

  • PriceCalculator を編集しない(理想)🙆‍♀️
  • 既存のルールも編集しない(理想)🙆‍♀️
  • やることは「Ruleを新規作成+登録」だけ✨
  • テストは「新Ruleのテストを足す」だけ🧪➕

これができたら、OCPの勝ち〜!🏆🎉


8) AI(Copilot/Codex系)に頼るときのコツ🤖📝✨

便利プロンプト例(そのまま投げてOK)💬

  • 「PricingRule interfaceに従って “BirthdayDiscountRule” を実装して。入力は PricingContext、割引は合計から200円引き。テストもVitestで書いて」🎂🧪
  • 「SetDiscountRuleの仕様を、COFFEE+SANDだけじゃなく TEA+CAKEも対象にして。読みやすくリファクタして」🍰🫖
  • 「このルール適用順で不具合が出るケースある?境界値テスト案を出して」🧠🔍

ただし注意⚠️

AIは“それっぽい嘘”も混ぜることあるから、 ✅ テスト と ✅ 差分レビュー は人間が握ろうね〜!👩‍💻🔍


9) ミニ課題(やってみよ〜!)🎒✨

課題A:誕生日割🎂(固定200円引き)

  • isBirthday: boolean を PricingContext に追加して
  • BirthdayDiscountRule を新規作成
  • PriceCalculatorは 編集禁止 🙅‍♀️(組み立てで追加するだけ!)

課題B:上限つき割引🧢(割引は最大500円まで)

  • 雨の日割を「最大500円割引まで」に変更したい → 既存Ruleを修正してもOKだけど、できれば 「RainyDayDiscountRuleV2」を新規追加して差し替える案も考えてみてね🤔✨

課題C:ルールの順序をテストで守る🔒

  • 「学割→雨割」順を守りたいなら、順序が変わると落ちるテストを書いてみよう🧪💥

まとめ🌸✨

  • OCPは「変わりやすいところに差し替え口を作る」がコツ🎯
  • 料金計算のコアは ルールを適用するだけ にすると強い🛡️
  • 新割引は Ruleを追加するだけ に寄せられると、未来が平和〜🕊️💖

次の章(第16章)は、差し替えが増えたときに爆発しがちな LSP(置換可能性) に突入するよ〜!🔁🧩✨