A: コーディング — ガード節(早期リターン)× Result型設計

2026-04-13 (Day 1) A: コーディング/アルゴリズム ★★★☆☆ クーポン適用ロジック 「良いコード・悪いコードで学ぶ設計入門」Ch6

概要

🚪

ガード節(早期リターン)

失敗条件をメソッドの先頭で弾き、正常系を最後の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 — ネスト型(5段) if coupon.is_active if cart.total >= min if membership >= req if total <= max if remaining > 0 return ok=True else → return False else → return False else → return False else → return False else → return False ネスト深度 = 5 / 読みにくい ✓ After — ガード節型 if not coupon.is_active → ✗ if cart.total < min → ✗ if membership < req → ✗ if total > max → ✗ if remaining <= 0 → ✗ return ok=True(正常系のみ) ネスト深度 = 0 / 上から下に読める

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になり、コードの「読む方向」が一方向(上から下)になる。
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が内側 / dictで型情報なし / 返却値の構造が不定 / float("inf")をDecimalと混算して精度が壊れる。

設計パターン対応表

パターン適用箇所
ガード節(早期リターン)apply_coupon()の各バリデーションCh6 条件分岐の単純化
不変オブジェクトfrozen=True dataclassCh4 不変の活用
Result型CouponApplyResultCh3 クラスで表現する
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 で表現し、「不正な状態を返せない」設計にすることがドメインロジックの堅牢化につながる。

自己評価

自分の回答

気づき・メモ