概要
ガード節(早期リターン)
失敗条件をメソッドの先頭で弾き、正常系を最後の1行に収める。ネストが消えコードが読みやすくなる。
型安全なResult型
dictの代わりに@dataclass(frozen=True)でResult型を定義し、型情報を保持する。
不変オブジェクト
入力を変更せず、結果を新しいオブジェクトで返す。副作用ゼロで並行処理・テストが容易になる。
Decimal で精度保証
金額計算はfloatではなくDecimalを使用。0.1 + 0.2 != 0.3問題を防ぐ。
問題
ECサイトの販促システムのクーポン適用ロジック。以下の「悪いコード」を読んで問題点を指摘し、改善してください。
背景
| 項目 | 内容 |
|---|---|
| コンテキスト | MOps(販促)チームのクーポン適用ロジック |
| 呼び出し元 | 複数の販促バッチ・APIから参照される |
| 課題 | ネストが深くてテストしにくい。型情報がない。 |
制約・前提条件
- Python 3.12 を使用
- ユーザー・カート・クーポンはドメインオブジェクトとして表現
- 割引は注文合計金額に対するパーセンテージ(0.0〜1.0)
- エラーは例外ではなくResult型で返す(raise不可)
- 金額計算は
decimal.Decimalを使用
期待する回答形式: (1) 問題点の列挙、(2) 改善後コード、(3) 実行例、(4) 適用した設計パターン名と書籍の対応章
悪いコード (Before)
このコードには 4つの設計上の問題 が隠れています。見つけてみてください。
def apply_coupon(user: dict, cart: dict, coupon: dict) -> dict:
result = dict(cart)
if coupon["is_active"]:
if result["total"] >= coupon["min_order"]:
if user["membership_days"] >= coupon["required_days"]:
if result["total"] <= coupon.get("max_order", float("inf")):
if coupon["remaining_count"] > 0:
discount = result["total"] * coupon["discount_rate"]
result["total"] -= discount
result["applied_coupon"] = coupon["code"]
return {"ok": True, "cart": result}
return {"ok": False, "error": "使用回数上限超過"}
return {"ok": False, "error": "注文金額が上限を超えています"}
return {"ok": False, "error": "会員歴不足"}
return {"ok": False, "error": "最低注文金額未満"}
return {"ok": False, "error": "クーポン無効"}
ヒント(段階的開示)
ヒント1 — 方向性
ネストが深くなる原因は「成功条件を全てif内に入れようとする」発想にあります。
「失敗条件を先に返す(ガード節)」という発想の転換が鍵です。
また
dict を使う限り型情報が消えます。
ヒント2 — アプローチ(4ステップ)
@dataclass(frozen=True, slots=True)で User / Cart / Coupon をモデル化し dict を排除する- 各バリデーション条件を独立したガード節に分解する(1条件1リターン)
- 返り値も
dataclassの Result 型で型安全にする - 金額は
Decimal+quantize(ROUND_DOWN)で端数処理を明示する
ヒント3 — 目指す構造
✗ Before(ネスト5段)
def apply_coupon(user: dict, cart: dict,
coupon: dict) -> dict:
if coupon["is_active"]:
if cart["total"] >= coupon["min_order"]:
if user["membership_days"] >= ...:
# さらにネスト...
return {"ok": True, "cart": ...}
return {"ok": False, "error": "..."}
✓ After(ガード節)
def apply_coupon(user: User, cart: Cart,
coupon: Coupon) -> CouponApplyResult:
# ガード節: 失敗を先に弾く
if not coupon.is_active:
return CouponApplyResult(ok=False, ...)
if cart.total < coupon.min_order:
return CouponApplyResult(ok=False, ...)
# ...残りのガード節
# 正常系は最後の1行のみ
return CouponApplyResult(ok=True, ...)
問題点分析
| # | 問題点 | 分類 | 影響 | 改善方法 |
|---|---|---|---|---|
| 1 | ネスト5段でhappy pathが内側、失敗パスが末端 | 可読性 | 読みにくく変更しにくい | ガード節(早期リターン)で失敗を先に返す |
| 2 | dictで入力を受け取り戻り値もdict |
型なし | 型情報なし・ドメイン概念が消える | @dataclass(frozen=True)でモデル化 |
| 3 | 返却値の構造が不定({"ok":True,"cart":...}と{"ok":False,"error":...}が混在) |
不整合 | 呼び出し側で.get()地獄になる |
CouponApplyResultdataclassで統一 |
| 4 | float("inf")を金額上限デフォルト値に使用 |
精度問題 | DecimalとfloatのMixin計算で精度が壊れる | MAX_ORDER_DEFAULT: Final[Decimal]を定義 |
ガード節パターン — 制御フロー構造図
ネスト型(Before)とガード節型(After)の制御フローの違い。
Before(左)は成功条件を全てネストで積み重ね、失敗パスがコードの末端に散在する。After(右)は失敗条件をガード節で先頭に集め、正常系を最後の1行に収めることで「上から下に読める」コードになる。
模範解答
from dataclasses import dataclass
from decimal import Decimal, ROUND_DOWN
from typing import Final
MAX_ORDER_DEFAULT: Final[Decimal] = Decimal("9999999")
@dataclass(frozen=True, slots=True)
class User:
user_id: str
membership_days: int
@dataclass(frozen=True, slots=True)
class Cart:
cart_id: str
total: Decimal
@dataclass(frozen=True, slots=True)
class Coupon:
code: str
is_active: bool
min_order: Decimal
required_days: int
discount_rate: Decimal
remaining_count: int
max_order: Decimal = MAX_ORDER_DEFAULT
@dataclass(frozen=True, slots=True)
class CouponApplyResult:
ok: bool
error: str = ""
discount: Decimal = Decimal("0")
applied_code: str = ""
def apply_coupon(user: User, cart: Cart, coupon: Coupon) -> CouponApplyResult:
"""クーポンをカートに適用し結果を返す(副作用なし)。
全バリデーションをガード節で先に弾き、
正常系は最後の1行に集約する。
"""
# ガード節: 失敗条件を先に返す(早期リターン)
if not coupon.is_active:
return CouponApplyResult(ok=False, error="クーポンが無効です")
if cart.total < coupon.min_order:
return CouponApplyResult(ok=False, error=f"最低注文金額 {coupon.min_order}円 未満です")
if user.membership_days < coupon.required_days:
return CouponApplyResult(ok=False, error=f"会員歴 {coupon.required_days}日 以上が必要です")
if cart.total > coupon.max_order:
return CouponApplyResult(ok=False, error=f"注文金額が上限 {coupon.max_order}円 を超えています")
if coupon.remaining_count <= 0:
return CouponApplyResult(ok=False, error="クーポンの使用回数上限に達しました")
# 正常系: ここまで到達したら全条件を満たしている
discount = (cart.total * coupon.discount_rate).quantize(Decimal("1"), rounding=ROUND_DOWN)
return CouponApplyResult(ok=True, discount=discount, applied_code=coupon.code)
# --- 実行例 ---
user = User(user_id="u001", membership_days=30)
cart = Cart(cart_id="c001", total=Decimal("3000"))
coupon = Coupon(
code="SPRING20",
is_active=True,
min_order=Decimal("2000"),
required_days=7,
discount_rate=Decimal("0.20"),
remaining_count=5,
)
result = apply_coupon(user, cart, coupon)
if result.ok:
print(f"クーポン適用成功: {result.applied_code}")
print(f"割引額: {result.discount}円")
print(f"支払額: {cart.total - result.discount}円")
else:
print(f"適用失敗: {result.error}")
# 出力:
# クーポン適用成功: SPRING20
# 割引額: 600円
# 支払額: 2400円
# 失敗ケース
cart_cheap = Cart(cart_id="c002", total=Decimal("1500"))
result2 = apply_coupon(user, cart_cheap, coupon)
print(result2.error) # 最低注文金額 2000円 未満です
ポイント解説
1
ガード節(早期リターン)パターン(Ch6「条件分岐の単純化」)
失敗条件をメソッドの先頭で弾くことで、正常系を最後の1行に収める。ネスト深度が0になり、コードの「読む方向」が一方向(上から下)になる。
失敗条件をメソッドの先頭で弾くことで、正常系を最後の1行に収める。ネスト深度が0になり、コードの「読む方向」が一方向(上から下)になる。
2
副作用ゼロ設計(Ch4「不変の活用」)
悪いコードは
悪いコードは
result = dict(cart) でdictを書き換えていた。改善後は入力を一切変更せず、結果を新しいオブジェクト(CouponApplyResult)で返す。並行処理・テスト・再利用が容易になる。
3
型安全なResult型(Ch3「クラスで表現する」)
{"ok": True, "cart": result} というdictでの返却は型情報がゼロ。@dataclass(frozen=True) のResult型は .ok / .discount / .error が型補完で確認でき、誤ったフィールド名アクセスをIDEが検出できる。
4
悪いコードの問題点 4点
ネスト5段でhappy pathが内側 /
ネスト5段でhappy pathが内側 /
dictで型情報なし / 返却値の構造が不定 / float("inf")をDecimalと混算して精度が壊れる。
設計パターン対応表
| パターン | 適用箇所 | 章 |
|---|---|---|
| ガード節(早期リターン) | apply_coupon()の各バリデーション | Ch6 条件分岐の単純化 |
| 不変オブジェクト | frozen=True dataclass | Ch4 不変の活用 |
| Result型 | CouponApplyResult | Ch3 クラスで表現する |
| Decimal精度保証 | 金額計算全般 | ECサイト実務必須 |
実務への応用
MOps/販促システムでの活用場面
- 新条件の追加: 「特定商品カテゴリのみ・初回購入者のみ」追加時に、既存のネストを変えずガード節を1行追加するだけで対応できる
- DataDogメトリクス:
CouponApplyResultを DataDogのカスタムメトリクスとして記録すれば、errorフィールドをerror_reasonタグにして失敗理由別の分布が可視化できる - BigQuery連携: 型が明確なdataclassから直接BigQueryへマッピングできる(Pydantic v2 の
model_validateとも相性が良い)
次のステップ
発展問題:
複数クーポンの組み合わせ適用(先着・積み重ねルール)を
CouponPolicy Protocol と Strategy パターンで実装する
- 参考: 「良いコード・悪いコードで学ぶ設計入門」第6章「条件分岐の単純化」・第8章「密結合」
今日のまとめ
ネストが深い条件分岐はガード節(早期リターン)で失敗条件を先に弾くだけで劇的に読みやすくなる。返り値は型情報のないdictではなく
frozen dataclass で表現し、「不正な状態を返せない」設計にすることがドメインロジックの堅牢化につながる。