弱点補強 (A コーディング) — Protocol × StrEnum × Decimal × frozen dataclass(クーポン適用エンジン Bad→Good)

2026-06-14 (Day 69) 日曜 弱点補強 ★★★★☆ Python 3.12 / Protocol / StrEnum / Decimal / frozen dataclass 良いコード設計入門 Ch2 / Ch4 / Ch6 / Ch7 / Ch10

概要

🔌

Protocol × Strategy パターンで Open/Closed 原則(Ch6)

if coupon_type == "fixed": elif ... elif ... の if-elif チェーンは新クーポンタイプ追加のたびに apply を修正する必要がある(Open/Closed 原則違反)。CouponStrategy Protocol + _STRATEGY_MAP に分離することで、新タイプ追加は新クラス追加だけで完結し、既存コードは変更不要になる。

🔢

Decimal で金額計算の精度を保証(Ch2)

float99.9 * 0.1 = 9.989999... の精度誤差は ECサイトの売上計算・消費税・POSレジとの照合で深刻なバグになる。Decimal + ROUND_HALF_UP + quantize(Decimal("1")) で円単位の確実な金額計算を実現する。

🏷️

StrEnum でマジックストリング排除(Ch7)

coupon["type"] == "fixed" はタイポで即バグ。IDE 補完も効かない。CouponType.FIXEDStrEnum)にすることで型チェッカーが誤字を検出し、match 文の網羅性チェックが効くようになる。Pydantic v2 の model_validate と組み合わせると文字列からの型安全な変換も自動になる。

📦

frozen dataclass ApplyResult で型安全な結果(Ch4)

{"discounted_total": ..., "discount": ...} の dict は result["discoutned_total"] のタイポで KeyError になる。@dataclass(frozen=True, slots=True)ApplyResultresult.discounted_total で型安全にアクセスでき、@property discount_rate で派生値も計算できる。slots=True はメモリ効率も改善する。

問題

ECサイト MOps チームの「クーポン適用エンジン」には、型安全性・責務分離・拡張性に関する深刻な設計上の問題がある。以下の「悪いコード」は、複数のクーポンタイプ(fixed / percent / freeship)を処理する CouponEngine の実装例です。問題点を全て洗い出しProtocolTypeVar@dataclass(frozen=True)StrEnummatch 文・DecimalPydantic v2 を使って Bad→Good にリファクタリングしてください。

制約・前提条件

  • Python 3.12+、DecimalROUND_HALF_UP)、Pydantic v2field_validator)、Protocol(ストラテジーパターン)、StrEnummatch 文 を使うこと
  • 各クーポンタイプは独立した Protocol 実装クラスに分離すること(Open/Closed 原則)
  • 金額は Decimal で計算し、ROUND_HALF_UP で丸め、quantize(Decimal("1")) で円単位に統一すること
  • 割引後合計が 0 未満にならないよう max(ZERO, ...) でガードすること
  • 処理結果は @dataclass(frozen=True, slots=True)ApplyResult として返すこと
  • Google スタイル docstring・インラインコメント・名前付き定数を含めること
期待する回答形式: 問題点の列挙(番号付き)+ 改善後コード(Google スタイル docstring・インラインコメント・名前付き定数含む)+ 実行例(input→output)+ 適用した設計パターン名と書籍の対応章

悪いコード (Before)

このコードには 7つの設計上の問題 が隠れています。見つけてみてください。
bad_coupon_engine.py — 型なし・float精度誤差・例外握りつぶし・拡張困難
class CouponEngine:
    def apply(self, coupon, order):
        # 問題1: 型ヒントなし(引数・戻り値に型情報がない)
        if coupon["type"] == "fixed":
            # 問題2: float で金額計算(精度誤差)
            discount = float(coupon["amount"])
        elif coupon["type"] == "percent":
            discount = float(order["total"]) * float(coupon["rate"]) / 100
        elif coupon["type"] == "freeship":
            discount = float(order["shipping_fee"])
        else:
            # 問題3: 不明タイプを無視(例外を握りつぶす)
            discount = 0.0

        result = float(order["total"]) - discount
        # 問題4: マイナスになるケースを未考慮(過剰割引)
        return {"discounted_total": result, "discount": discount}
        # 問題5: dict で返す(型安全性なし・タイポで KeyError)

def apply_all(orders, coupons):
    # 問題6: coupon と order の組み合わせ検証なし(件数不一致でバグ)
    engine = CouponEngine()
    results = []
    for i in range(len(orders)):
        # 問題7: インデックス for ループ(Pythonic でない)
        r = engine.apply(coupons[i], orders[i])
        results.append(r)
    return results
問題点サマリー(7点)
1型ヒントなし(Ch2) — 引数・戻り値に型なし。IDE 補完・型チェッカーが使えない。Pydantic v2 Order / CouponConfig で入力を型安全に
2float で金額計算(Ch2)99.9 * 0.1 = 9.989999... の精度誤差。Decimal + ROUND_HALF_UP で金額精度を保証
3未知タイプを無視・例外握りつぶし(Ch10)else: discount = 0.0 は不正クーポンを黙って無視する。match-case _ : raise ValueError で明示的にエラー
4過剰割引ガードなし(Ch10) — 割引額が注文合計を超えると discounted_total が負になる。max(ZERO, total - discount) でガード
5dict で返す(Ch4) — タイポで KeyError。@dataclass(frozen=True, slots=True) ApplyResult で型安全な値オブジェクトに
6件数不一致チェックなし(Ch10)orderscoupons の件数が違うと IndexError。長さ検証と ValueError を事前に
7インデックス for ループ(Ch11 Pythonic)for i in range(len(orders)): はアンチパターン。zip(orders, configs) + リスト内包表記に

ヒント(段階的開示)

ヒント1 — 方向性
クーポンタイプごとに処理が異なる if-elif は「新タイプを追加するたびに apply を修正する」設計(Open/Closed 原則違反)。Protocol を定義してクーポンタイプを実装クラスに分離すれば、新タイプ追加は新クラス追加だけで済む。金額計算は float ではなく Decimal を使うこと(例: Decimal("99.9") * Decimal("0.1") は正確だが 99.9 * 0.19.989999...)。
ヒント2 — アプローチ
  • StrEnumCouponType を定義(FIXED / PERCENT / FREESHIP
  • ProtocolCouponStrategy インターフェースを定義(def calc_discount(order, config) -> Decimal
  • FixedCouponStrategy, PercentCouponStrategy, FreeshipCouponStrategyCouponStrategy として実装
  • CouponTypeCouponStrategy のマッピングを dict[CouponType, CouponStrategy] で管理(_STRATEGY_MAP
  • match config.type でストラテジーを選択
  • @dataclass(frozen=True, slots=True)ApplyResult で結果を返す
  • Pydantic v2Order / CouponConfig モデルで入力バリデーション
ヒント3 — コードの骨格
from decimal import Decimal, ROUND_HALF_UP
from enum import StrEnum
from typing import Protocol, Final
from dataclasses import dataclass
from pydantic import BaseModel, field_validator

ZERO: Final[Decimal] = Decimal("0")
HUNDRED: Final[Decimal] = Decimal("100")
MONEY_PLACES: Final[Decimal] = Decimal("1")  # 円単位

class CouponType(StrEnum):
    FIXED    = "fixed"
    PERCENT  = "percent"
    FREESHIP = "freeship"

class Order(BaseModel):
    order_id:     str
    total:        Decimal
    shipping_fee: Decimal

class CouponConfig(BaseModel):
    coupon_id: str
    type:      CouponType
    amount:    Decimal | None = None
    rate:      Decimal | None = None

class CouponStrategy(Protocol):
    def calc_discount(self, order: Order, config: CouponConfig) -> Decimal: ...

@dataclass(frozen=True, slots=True)
class ApplyResult:
    order_id:         str
    coupon_id:        str
    coupon_type:      CouponType
    original_total:   Decimal
    discount:         Decimal
    discounted_total: Decimal

    @property
    def discount_rate(self) -> Decimal:
        if self.original_total == ZERO:
            return ZERO
        return (self.discount / self.original_total * HUNDRED).quantize(
            Decimal("0.01"), rounding=ROUND_HALF_UP
        )

class FixedCouponStrategy:
    def calc_discount(self, order: Order, config: CouponConfig) -> Decimal:
        if config.amount is None:
            raise ValueError(f"FIXED には amount が必要")
        return min(config.amount, order.total).quantize(MONEY_PLACES, rounding=ROUND_HALF_UP)

_STRATEGY_MAP: Final[dict[CouponType, CouponStrategy]] = {
    CouponType.FIXED:    FixedCouponStrategy(),
    CouponType.PERCENT:  PercentCouponStrategy(),
    CouponType.FREESHIP: FreeshipCouponStrategy(),
}

問題点分析(7点)

#問題点分類改善方法
1型ヒントなし型安全性 Ch2Pydantic v2 Order / CouponConfig で入力を型安全に
2float で金額計算(精度誤差)型安全性 Ch2Decimal + ROUND_HALF_UP + quantize で精度保証
3未知タイプを黙って無視エラー処理 Ch10match-case _ : raise ValueError で明示的エラー
4過剰割引ガードなしエラー処理 Ch10max(ZERO, total - discount) + min(amount, total)
5dict で結果を返す値オブジェクト Ch4@dataclass(frozen=True, slots=True) ApplyResult
6件数不一致チェックなしエラー処理 Ch10if len(orders) != len(configs): raise ValueError
7インデックス for ループPythonic Ch11zip(orders, configs) + リスト内包表記

模範解答

Before — float精度誤差・型なし・例外握りつぶし・拡張困難
class CouponEngine:
    def apply(self, coupon, order):
        if coupon["type"] == "fixed":
            discount = float(coupon["amount"])          # float 精度誤差
        elif coupon["type"] == "percent":
            discount = float(order["total"]) * float(coupon["rate"]) / 100
        elif coupon["type"] == "freeship":
            discount = float(order["shipping_fee"])
        else:
            discount = 0.0  # 不明タイプを無視

        result = float(order["total"]) - discount  # 負になるケースあり
        return {"discounted_total": result, "discount": discount}  # dict(型なし)

def apply_all(orders, coupons):
    engine = CouponEngine()
    results = []
    for i in range(len(orders)):   # インデックスループ(非Pythonic)
        r = engine.apply(coupons[i], orders[i])
        results.append(r)
    return results
After — Protocol × StrEnum × Decimal × frozen dataclass × match 文
"""coupon_engine.py — Protocol × StrEnum × Decimal × frozen dataclass × match 文。

Ch2: 型の活用(StrEnum / Pydantic v2 / Protocol)
Ch4: コレクション(frozen dataclass ApplyResult)
Ch6: 条件分岐の統一(match 文 / Strategy マッピング)
Ch7: 名前付き定数(マジックナンバー排除)
Ch10: エラー処理(ValidationError / 過剰割引ガード)
"""
from __future__ import annotations
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
from enum import StrEnum
from typing import Final, Protocol
from pydantic import BaseModel, field_validator

# ── 名前付き定数(マジックナンバー禁止)────────────────────
ZERO: Final[Decimal] = Decimal("0")
HUNDRED: Final[Decimal] = Decimal("100")
MONEY_PLACES: Final[Decimal] = Decimal("1")   # 円単位(整数精度)

# ── StrEnum: マジックストリング排除 ─────────────────────────
class CouponType(StrEnum):
    FIXED    = "fixed"      # 固定金額割引
    PERCENT  = "percent"    # パーセント割引
    FREESHIP = "freeship"   # 送料無料

# ── Pydantic v2 入力バリデーション ──────────────────────────
class Order(BaseModel):
    order_id:     str
    total:        Decimal
    shipping_fee: Decimal

    @field_validator("total", "shipping_fee")
    @classmethod
    def must_be_non_negative(cls, v: Decimal) -> Decimal:
        if v < ZERO:
            raise ValueError(f"金額は 0 以上が必要。受け取り値: {v}")
        return v

class CouponConfig(BaseModel):
    coupon_id: str
    type:      CouponType
    amount:    Decimal | None = None  # FIXED 専用
    rate:      Decimal | None = None  # PERCENT 専用(0〜100)

    @field_validator("rate")
    @classmethod
    def validate_rate(cls, v: Decimal | None) -> Decimal | None:
        if v is not None and not (ZERO <= v <= HUNDRED):
            raise ValueError(f"rate は 0〜100 が必要。受け取り値: {v}")
        return v

# ── Protocol: ストラテジーインターフェース ──────────────────
class CouponStrategy(Protocol):
    def calc_discount(self, order: Order, config: CouponConfig) -> Decimal: ...

# ── 各クーポンタイプの実装 ──────────────────────────────────
class FixedCouponStrategy:
    def calc_discount(self, order: Order, config: CouponConfig) -> Decimal:
        if config.amount is None:
            raise ValueError(f"FIXED には amount が必要: {config.coupon_id}")
        # 割引額が注文合計を超えないようにガード(過剰割引防止)
        return min(config.amount, order.total).quantize(
            MONEY_PLACES, rounding=ROUND_HALF_UP
        )

class PercentCouponStrategy:
    def calc_discount(self, order: Order, config: CouponConfig) -> Decimal:
        if config.rate is None:
            raise ValueError(f"PERCENT には rate が必要: {config.coupon_id}")
        raw = order.total * (config.rate / HUNDRED)
        return raw.quantize(MONEY_PLACES, rounding=ROUND_HALF_UP)

class FreeshipCouponStrategy:
    def calc_discount(self, order: Order, config: CouponConfig) -> Decimal:
        return order.shipping_fee.quantize(MONEY_PLACES, rounding=ROUND_HALF_UP)

# ── Strategy マッピング(switch 代替)──────────────────────
_STRATEGY_MAP: Final[dict[CouponType, CouponStrategy]] = {
    CouponType.FIXED:    FixedCouponStrategy(),
    CouponType.PERCENT:  PercentCouponStrategy(),
    CouponType.FREESHIP: FreeshipCouponStrategy(),
}

# ── frozen dataclass: 型安全な結果オブジェクト ─────────────
@dataclass(frozen=True, slots=True)
class ApplyResult:
    order_id:         str
    coupon_id:        str
    coupon_type:      CouponType
    original_total:   Decimal
    discount:         Decimal
    discounted_total: Decimal

    @property
    def discount_rate(self) -> Decimal:
        if self.original_total == ZERO:
            return ZERO
        return (self.discount / self.original_total * HUNDRED).quantize(
            Decimal("0.01"), rounding=ROUND_HALF_UP
        )

# ── メインエンジン ──────────────────────────────────────────
class CouponEngine:
    def apply(self, order: Order, config: CouponConfig) -> ApplyResult:
        # match 文で宣言的なストラテジー選択(if-elif 排除)
        match config.type:
            case CouponType.FIXED | CouponType.PERCENT | CouponType.FREESHIP:
                strategy = _STRATEGY_MAP[config.type]
            case _:
                raise ValueError(f"未知のクーポンタイプ: {config.type!r}")

        discount = strategy.calc_discount(order, config)
        # 割引後合計が 0 未満にならないよう保護
        discounted_total = max(ZERO, order.total - discount)
        return ApplyResult(
            order_id=order.order_id,
            coupon_id=config.coupon_id,
            coupon_type=config.type,
            original_total=order.total,
            discount=discount,
            discounted_total=discounted_total,
        )

    def apply_all(
        self, orders: list[Order], configs: list[CouponConfig]
    ) -> list[ApplyResult]:
        if len(orders) != len(configs):
            raise ValueError(
                f"件数不一致: orders={len(orders)}, configs={len(configs)}"
            )
        # zip でペアリング(インデックスループ排除)
        return [self.apply(o, c) for o, c in zip(orders, configs)]
from decimal import Decimal
from coupon_engine import CouponEngine, Order, CouponConfig, CouponType

engine = CouponEngine()

# ── FIXED クーポン(500円引き)──
order  = Order(order_id="o-001", total=Decimal("3000"), shipping_fee=Decimal("500"))
config = CouponConfig(coupon_id="c-fixed", type=CouponType.FIXED, amount=Decimal("500"))
r = engine.apply(order, config)
print(r.discounted_total)  # Decimal("2500")
print(r.discount_rate)     # Decimal("16.67")  ← 小数点以下2桁

# ── PERCENT クーポン(15%引き)──
config2 = CouponConfig(coupon_id="c-pct", type=CouponType.PERCENT, rate=Decimal("15"))
r2 = engine.apply(order, config2)
print(r2.discount)          # Decimal("450")  ← ROUND_HALF_UP で円単位
print(r2.discounted_total)  # Decimal("2550")

# ── FREESHIP クーポン(送料無料)──
config3 = CouponConfig(coupon_id="c-ship", type=CouponType.FREESHIP)
r3 = engine.apply(order, config3)
print(r3.discount)          # Decimal("500")
print(r3.discounted_total)  # Decimal("2500")

# ── 過剰割引ガード(割引が合計を超える場合)──
config4 = CouponConfig(coupon_id="c-over", type=CouponType.FIXED, amount=Decimal("9999"))
r4 = engine.apply(order, config4)
print(r4.discount)          # Decimal("3000")  ← min(9999, 3000) でガード
print(r4.discounted_total)  # Decimal("0")     ← max(0, 3000-3000) でガード

# ── frozen dataclass の検証 ──
try:
    r.discounted_total = Decimal("999")  # FrozenInstanceError
except Exception as e:
    print(type(e).__name__)  # FrozenInstanceError
ポイント適用した設計原則/パターン書籍対応章
Protocol CouponStrategy + _STRATEGY_MAPStrategy パターン / Open/Closed 原則Ch6
Decimal + ROUND_HALF_UP + quantize型の活用 / 金額精度保証Ch2
StrEnum CouponTypeマジックストリング排除 / 名前付き定数Ch7
@dataclass(frozen=True, slots=True) ApplyResult値オブジェクト / 不変性 / メモリ効率Ch4
Pydantic v2 field_validatorFail-Fast 入力検証 / 型安全Ch2
match config.type ... case _: raise条件分岐の統一 / 防衛的プログラミングCh6 / Ch10
max(ZERO, ...) / min(amount, total)過剰割引ガード / エラー処理Ch10
zip(orders, configs) + リスト内包表記Pythonic / テスト容易性Ch11
# tests/test_coupon_engine.py
import pytest
from decimal import Decimal
from coupon_engine import (
    CouponEngine, Order, CouponConfig, CouponType, ApplyResult, ZERO
)

@pytest.fixture
def engine() -> CouponEngine:
    return CouponEngine()

@pytest.fixture
def base_order() -> Order:
    return Order(order_id="o-001", total=Decimal("3000"), shipping_fee=Decimal("500"))

class TestFixedCoupon:
    def test_normal_discount(self, engine, base_order):
        config = CouponConfig(coupon_id="c1", type=CouponType.FIXED, amount=Decimal("500"))
        r = engine.apply(base_order, config)
        assert r.discount          == Decimal("500")
        assert r.discounted_total  == Decimal("2500")
        assert r.discount_rate     == Decimal("16.67")

    def test_over_discount_guard(self, engine, base_order):
        """割引額が合計を超える場合: discount は合計額、discounted_total は 0"""
        config = CouponConfig(coupon_id="c2", type=CouponType.FIXED, amount=Decimal("9999"))
        r = engine.apply(base_order, config)
        assert r.discount         == base_order.total  # min(9999, 3000) = 3000
        assert r.discounted_total == ZERO              # max(0, 3000-3000) = 0

class TestPercentCoupon:
    def test_15_percent(self, engine, base_order):
        config = CouponConfig(coupon_id="c3", type=CouponType.PERCENT, rate=Decimal("15"))
        r = engine.apply(base_order, config)
        assert r.discount         == Decimal("450")   # 3000 * 0.15 = 450
        assert r.discounted_total == Decimal("2550")

    def test_invalid_rate_raises(self):
        with pytest.raises(Exception):
            CouponConfig(coupon_id="c-err", type=CouponType.PERCENT, rate=Decimal("101"))

class TestFreeshipCoupon:
    def test_freeship(self, engine, base_order):
        config = CouponConfig(coupon_id="c4", type=CouponType.FREESHIP)
        r = engine.apply(base_order, config)
        assert r.discount         == Decimal("500")
        assert r.discounted_total == Decimal("2500")

class TestApplyAll:
    def test_length_mismatch_raises(self, engine, base_order):
        with pytest.raises(ValueError, match="件数不一致"):
            engine.apply_all([base_order], [])

    def test_multiple_orders(self, engine, base_order):
        configs = [
            CouponConfig(coupon_id="c1", type=CouponType.FIXED, amount=Decimal("100")),
            CouponConfig(coupon_id="c2", type=CouponType.FREESHIP),
        ]
        results = engine.apply_all([base_order, base_order], configs)
        assert len(results) == 2
        assert all(isinstance(r, ApplyResult) for r in results)

class TestApplyResultIsFrozen:
    def test_frozen(self, engine, base_order):
        config = CouponConfig(coupon_id="c1", type=CouponType.FIXED, amount=Decimal("100"))
        r = engine.apply(base_order, config)
        with pytest.raises(Exception):  # FrozenInstanceError
            r.discount = Decimal("999")  # type: ignore[misc]

設計図 — Protocol × Strategy パターン × frozen dataclass

Strategy パターン — Protocol × _STRATEGY_MAP × CouponEngine Order (Pydantic v2) order_id: str total: Decimal shipping_fee: Decimal CouponConfig (Pydantic v2) coupon_id: str type: CouponType (StrEnum) amount / rate: Decimal | None CouponType (StrEnum) FIXED = "fixed" PERCENT = "percent" FREESHIP = "freeship" CouponEngine apply(order, config) → ApplyResult apply_all(orders, configs) → list[ApplyResult] «Protocol» CouponStrategy calc_discount(order, config) → Decimal structural subtyping — isinstance 不要 新タイプ追加 = 新クラス追加のみ(Open/Closed) _STRATEGY_MAP (Final dict) CouponType.FIXED → FixedCouponStrategy() CouponType.PERCENT → PercentCouponStrategy() match type FixedCouponStrategy discount = min(amount, total) → 過剰割引ガード .quantize(MONEY_PLACES, ROUND_HALF_UP) PercentCouponStrategy discount = total * (rate / 100) → Decimal 精度(float 誤差なし) .quantize(MONEY_PLACES, ROUND_HALF_UP) FreeshipCouponStrategy discount = shipping_fee → 送料全額を割引 .quantize(MONEY_PLACES, ROUND_HALF_UP) @dataclass(frozen=True, slots=True) ApplyResult order_id / coupon_id / coupon_type original_total: Decimal / discount: Decimal / discounted_total: Decimal @property discount_rate → Decimal (0.01 精度) frozen=True: 生成後変更不可 / slots=True: メモリ効率改善 / pytest で直接生成可 discounted_total = max(ZERO, total - discount) ← 負にならない保証 returns

ポイント解説

1 Protocol × Strategy パターンで Open/Closed 原則(Ch6)
if coupon_type == "fixed": elif "percent": ... の if-elif チェーンは「新クーポンタイプを追加するたびに apply メソッドを修正」する必要がある(OCP 違反)。Protocol CouponStrategy + _STRATEGY_MAP に分離すれば、新タイプ追加は「新クラスを書いて _STRATEGY_MAP に追加」するだけ。apply 本体は変更不要になる。
2 Decimal で金額精度を保証(Ch2)
float(3000) * 0.15 = 449.99999... の精度誤差は「カートの表示金額」と「会計システムの集計額」が 1 円ずれる原因になる。Decimal("3000") * Decimal("0.15") = Decimal("450.00") と正確。quantize(Decimal("1"), ROUND_HALF_UP) で円単位に統一し、監査に耐えられる金額精度を保証する。
3 StrEnum でマジックストリング排除・match 文で条件分岐統一(Ch7/Ch6)
coupon["type"] == "fiexd" のタイポは実行時まで気づけない。CouponType.FIXED(StrEnum)は IDE 補完・型チェッカーがタイポを即時検出する。match config.typecase _: で「网羅されていないケース」を明示的に処理でき、mypy も switch 的な網羅性を確認できる。
4 frozen=True, slots=True dataclass で値オブジェクト(Ch4)
result["discoutned_total"] のタイポで KeyError になる dict より、result.discounted_total の属性アクセスは型安全。slots=True__dict__ を使わないため 10〜20% のメモリ削減と属性アクセスの高速化。frozen=True は「処理結果を後から書き換える」意図しない副作用を防ぎ、pytest のアサーションが assert result == expected と簡潔に書ける。
5 過剰割引の二重ガード(Ch10)
FIXED クーポンで「5,000円引き」を「3,000円の注文」に適用すると discounted_total = -2,000 になる。FixedCouponStrategymin(amount, total)(割引額の上限を注文合計に)、CouponEngine.applymax(ZERO, total - discount)(合計の下限を 0 に)の二重ガードで確実に防ぐ。
6 zip + リスト内包表記でイディオムループ(Ch11 Pythonic)
for i in range(len(orders)): ... orders[i] ... coupons[i] は C 言語的スタイル。Python では zip(orders, configs) でペアを取り出し、[self.apply(o, c) for o, c in zip(orders, configs)] と 1 行で書く。zip は件数不一致でも短い方に合わせるため、事前に len の検証が重要(strict=True オプション(Python 3.10+)でも検出可能)。

実務への応用

  • 新クーポンタイプの追加コスト: PM から「来週セールで bundle クーポン(2点買うと 20% 引き)をリリースしたい」という要求が来た場合、BundleCouponStrategy クラスを作って _STRATEGY_MAP[CouponType.BUNDLE] = BundleCouponStrategy() と追加するだけ。CouponEngine.apply の修正は不要なため PR のレビュー範囲が最小になる
  • ECサイト金額計算の統一: 注文合計・割引額・消費税・最終支払額が全て Decimal で計算されていれば、POSレジ・BigQuery の売上 mart・会計 SaaS の数字が一致する。float が1か所でも混入すると月次精算で「1〜2円の誤差」が毎回発生し調査コストになる
  • Pydantic v2 バリデーションとの組み合わせ: Argo Workflows から渡される JSON ペイロードを Order.model_validate(raw_dict) で変換し、バリデーションエラーは早期に弾く。不正データが金額計算まで到達するバグを防ぐ Fail-Fast 設計
  • pytest での直接生成(テスタビリティ): ApplyResult(order_id="o1", coupon_id="c1", coupon_type=CouponType.FIXED, original_total=Decimal("1000"), discount=Decimal("100"), discounted_total=Decimal("900")) と直接コンストラクタで期待値を作れるため、モック不要でテストが書ける
  • 証券マン視点 — 金額精度と監査証跡: 割引計算の精度誤差は UX だけでなく消費税計算・会計監査・POS との照合に波及する。ROUND_HALF_UPquantize の統一は「監査証跡に耐えられる金額計算の基盤」として、エンジニアが財務上の責任を持つ設計判断になる

今日のまとめ

Protocol(Open/Closed 原則)+ StrEnum(マジックストリング排除)+ Decimal(金額精度保証)+ frozen dataclass(型安全な値オブジェクト)の4点セットは、ECサイトのクーポン適用エンジンを「拡張可能・精度保証・型安全・テスタブル」にするための Python 実践的最小構成であり、新クーポンタイプ追加コストをほぼゼロにするストラテジーパターンの実装として設計入門書の複数章が連動して機能する。

自己評価

自分の回答

気づき・メモ