概要
Protocol × Strategy パターンで Open/Closed 原則(Ch6)
if coupon_type == "fixed": elif ... elif ... の if-elif チェーンは新クーポンタイプ追加のたびに apply を修正する必要がある(Open/Closed 原則違反)。CouponStrategy Protocol + _STRATEGY_MAP に分離することで、新タイプ追加は新クラス追加だけで完結し、既存コードは変更不要になる。
Decimal で金額計算の精度を保証(Ch2)
float の 99.9 * 0.1 = 9.989999... の精度誤差は ECサイトの売上計算・消費税・POSレジとの照合で深刻なバグになる。Decimal + ROUND_HALF_UP + quantize(Decimal("1")) で円単位の確実な金額計算を実現する。
StrEnum でマジックストリング排除(Ch7)
coupon["type"] == "fixed" はタイポで即バグ。IDE 補完も効かない。CouponType.FIXED(StrEnum)にすることで型チェッカーが誤字を検出し、match 文の網羅性チェックが効くようになる。Pydantic v2 の model_validate と組み合わせると文字列からの型安全な変換も自動になる。
frozen dataclass ApplyResult で型安全な結果(Ch4)
{"discounted_total": ..., "discount": ...} の dict は result["discoutned_total"] のタイポで KeyError になる。@dataclass(frozen=True, slots=True) の ApplyResult は result.discounted_total で型安全にアクセスでき、@property discount_rate で派生値も計算できる。slots=True はメモリ効率も改善する。
問題
ECサイト MOps チームの「クーポン適用エンジン」には、型安全性・責務分離・拡張性に関する深刻な設計上の問題がある。以下の「悪いコード」は、複数のクーポンタイプ(fixed / percent / freeship)を処理する CouponEngine の実装例です。問題点を全て洗い出し、Protocol・TypeVar・@dataclass(frozen=True)・StrEnum・match 文・Decimal・Pydantic v2 を使って Bad→Good にリファクタリングしてください。
制約・前提条件
- Python 3.12+、
Decimal(ROUND_HALF_UP)、Pydantic v2(field_validator)、Protocol(ストラテジーパターン)、StrEnum、match文 を使うこと - 各クーポンタイプは独立した
Protocol実装クラスに分離すること(Open/Closed 原則) - 金額は
Decimalで計算し、ROUND_HALF_UPで丸め、quantize(Decimal("1"))で円単位に統一すること - 割引後合計が 0 未満にならないよう
max(ZERO, ...)でガードすること - 処理結果は
@dataclass(frozen=True, slots=True)のApplyResultとして返すこと - Google スタイル docstring・インラインコメント・名前付き定数を含めること
悪いコード (Before)
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
Order / CouponConfig で入力を型安全に99.9 * 0.1 = 9.989999... の精度誤差。Decimal + ROUND_HALF_UP で金額精度を保証else: discount = 0.0 は不正クーポンを黙って無視する。match-case _ : raise ValueError で明示的にエラーdiscounted_total が負になる。max(ZERO, total - discount) でガード@dataclass(frozen=True, slots=True) ApplyResult で型安全な値オブジェクトにorders と coupons の件数が違うと IndexError。長さ検証と ValueError を事前にfor i in range(len(orders)): はアンチパターン。zip(orders, configs) + リスト内包表記にヒント(段階的開示)
ヒント1 — 方向性
apply を修正する」設計(Open/Closed 原則違反)。Protocol を定義してクーポンタイプを実装クラスに分離すれば、新タイプ追加は新クラス追加だけで済む。金額計算は float ではなく Decimal を使うこと(例: Decimal("99.9") * Decimal("0.1") は正確だが 99.9 * 0.1 は 9.989999...)。
ヒント2 — アプローチ
StrEnumでCouponTypeを定義(FIXED / PERCENT / FREESHIP)ProtocolでCouponStrategyインターフェースを定義(def calc_discount(order, config) -> Decimal)FixedCouponStrategy,PercentCouponStrategy,FreeshipCouponStrategyをCouponStrategyとして実装CouponType→CouponStrategyのマッピングをdict[CouponType, CouponStrategy]で管理(_STRATEGY_MAP)match config.typeでストラテジーを選択@dataclass(frozen=True, slots=True)のApplyResultで結果を返すPydantic v2のOrder/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 | 型ヒントなし | 型安全性 Ch2 | Pydantic v2 Order / CouponConfig で入力を型安全に |
| 2 | float で金額計算(精度誤差) | 型安全性 Ch2 | Decimal + ROUND_HALF_UP + quantize で精度保証 |
| 3 | 未知タイプを黙って無視 | エラー処理 Ch10 | match-case _ : raise ValueError で明示的エラー |
| 4 | 過剰割引ガードなし | エラー処理 Ch10 | max(ZERO, total - discount) + min(amount, total) |
| 5 | dict で結果を返す | 値オブジェクト Ch4 | @dataclass(frozen=True, slots=True) ApplyResult |
| 6 | 件数不一致チェックなし | エラー処理 Ch10 | if len(orders) != len(configs): raise ValueError |
| 7 | インデックス for ループ | Pythonic Ch11 | zip(orders, configs) + リスト内包表記 |
模範解答
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
"""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_MAP | Strategy パターン / Open/Closed 原則 | Ch6 |
Decimal + ROUND_HALF_UP + quantize | 型の活用 / 金額精度保証 | Ch2 |
StrEnum CouponType | マジックストリング排除 / 名前付き定数 | Ch7 |
@dataclass(frozen=True, slots=True) ApplyResult | 値オブジェクト / 不変性 / メモリ効率 | Ch4 |
Pydantic v2 field_validator | Fail-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
ポイント解説
if coupon_type == "fixed": elif "percent": ... の if-elif チェーンは「新クーポンタイプを追加するたびに apply メソッドを修正」する必要がある(OCP 違反)。Protocol CouponStrategy + _STRATEGY_MAP に分離すれば、新タイプ追加は「新クラスを書いて _STRATEGY_MAP に追加」するだけ。apply 本体は変更不要になる。
float(3000) * 0.15 = 449.99999... の精度誤差は「カートの表示金額」と「会計システムの集計額」が 1 円ずれる原因になる。Decimal("3000") * Decimal("0.15") = Decimal("450.00") と正確。quantize(Decimal("1"), ROUND_HALF_UP) で円単位に統一し、監査に耐えられる金額精度を保証する。
coupon["type"] == "fiexd" のタイポは実行時まで気づけない。CouponType.FIXED(StrEnum)は IDE 補完・型チェッカーがタイポを即時検出する。match config.type は case _: で「网羅されていないケース」を明示的に処理でき、mypy も switch 的な網羅性を確認できる。
result["discoutned_total"] のタイポで KeyError になる dict より、result.discounted_total の属性アクセスは型安全。slots=True は __dict__ を使わないため 10〜20% のメモリ削減と属性アクセスの高速化。frozen=True は「処理結果を後から書き換える」意図しない副作用を防ぎ、pytest のアサーションが assert result == expected と簡潔に書ける。
FIXED クーポンで「5,000円引き」を「3,000円の注文」に適用すると
discounted_total = -2,000 になる。FixedCouponStrategy で min(amount, total)(割引額の上限を注文合計に)、CouponEngine.apply で max(ZERO, total - discount)(合計の下限を 0 に)の二重ガードで確実に防ぐ。
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_UPとquantizeの統一は「監査証跡に耐えられる金額計算の基盤」として、エンジニアが財務上の責任を持つ設計判断になる
今日のまとめ
Protocol(Open/Closed 原則)+ StrEnum(マジックストリング排除)+ Decimal(金額精度保証)+ frozen dataclass(型安全な値オブジェクト)の4点セットは、ECサイトのクーポン適用エンジンを「拡張可能・精度保証・型安全・テスタブル」にするための Python 実践的最小構成であり、新クーポンタイプ追加コストをほぼゼロにするストラテジーパターンの実装として設計入門書の複数章が連動して機能する。