コーディング/アルゴリズム — 販促クーポン管理モジュール リファクタリング

2026-05-04 (Day 4) A: コーディング設計 ★★★☆☆ Ch8: 条件分岐 × Ch9: コレクション × Ch10: 設計の悪魔 「良いコード・悪いコードで学ぶ設計入門」

問題

以下は「販促クーポン管理モジュール」の実装です。 このコードには設計上・Pythonイディオムの観点から複数の問題があります。 問題点を列挙し、Python 3.12+ のベストプラクティスで改善してください。

制約・前提条件

  • Python 3.12+ を使用すること
  • dataclass または @dataclass(slots=True) でクーポンを値オブジェクトとして表現すること
  • 「良いコード・悪いコードで学ぶ設計入門」Ch8(条件分岐)・Ch9(コレクション)・Ch10(設計の悪魔)の観点を活用すること
  • StrEnum を使ってステータスを型安全に扱うこと
  • マジックナンバー(9999999)は名前付き定数化すること
期待する回答形式:
1. 問題点の列挙(番号付き)
2. 改善後コード(Google スタイル docstring・インラインコメント・名前付き定数含む)
3. 実行例(input → output)
4. 適用した設計パターン名と書籍の対応章

悪いコード (Before)

このコードには 6つの設計上の問題 が隠れています。見つけてみてください。
# --- BAD CODE ---

COUPON_LIST = []

def add_coupon(code, discount, min_order, is_active, expires):
    COUPON_LIST.append({
        "code": code,
        "discount": discount,
        "min_order": min_order,
        "is_active": is_active,
        "expires": expires,
        "used_count": 0,
    })

def apply_coupon(code, order_amount):
    for c in COUPON_LIST:
        if c["code"] == code:
            if c["is_active"] == True:
                if c["discount"] > 0:
                    if order_amount >= c["min_order"]:
                        if c["used_count"] < 9999999:
                            c["used_count"] = c["used_count"] + 1
                            d = order_amount * c["discount"] / 100
                            return order_amount - d
                        else:
                            return None
                    else:
                        return None
                else:
                    return None
            else:
                return None
    return None

def get_active_coupons():
    result = []
    for c in COUPON_LIST:
        if c["is_active"] == True:
            result.append(c)
    return result

def deactivate_expired(now):
    for c in COUPON_LIST:
        if c["expires"] < now:
            c["is_active"] = False

ヒント(段階的開示)

ヒント1 — 問題の3分類
コードの問題は大きく3種類に分類できます。
① データ表現の問題(辞書をそのまま使っている)
② 条件分岐の構造的問題(ネスト・早期リターンの欠如)
③ コレクション操作の問題(グローバルミュータブル状態・ループの書き方)
ヒント2 — 各問題のヒント
  • クーポンを dict で表現しているため、キーの typo や型不一致が実行時まで検出されない
  • apply_couponif ネストは5段階 → 早期 return で逆条件を先に弾くことで平坦化できる
  • COUPON_LIST はグローバル変数かつミュータブル → クラスにカプセル化することで副作用を制御できる
  • used_count < 9999999 はマジックナンバー → MAX_USAGE_COUNT = 9_999_999 のように定数化
  • is_active == True は冗長な比較 → is_active で十分
ヒント3 — 目指す構造のスケルトン
from dataclasses import dataclass, field
from enum import StrEnum
from datetime import datetime

MAX_USAGE_COUNT = 9_999_999

class CouponStatus(StrEnum):
    ACTIVE = "active"
    INACTIVE = "inactive"

@dataclass(slots=True)
class Coupon:
    code: str
    discount_rate: int
    min_order_amount: int
    status: CouponStatus
    expires_at: datetime
    used_count: int = field(default=0)

    def is_usable(self, order_amount: int) -> bool:
        """早期return形式"""
        if self.status != CouponStatus.ACTIVE:
            return False
        # ... (各ガード節)
        return True

class CouponRepository:
    def __init__(self) -> None:
        self._coupons: dict[str, Coupon] = {}
    # apply / get_active_coupons / deactivate_expired を実装

問題点分析

#問題点分類改善方法
1 dict でクーポンを表現している データ表現 @dataclass(slots=True) で値オブジェクト化。型チェックと補完が効く
2 apply_coupon の5段ネストの if 条件分岐 早期 return で逆条件を先に弾く(Ch8: 早期 return)
3 COUPON_LIST グローバルミュータブル変数 コレクション CouponRepository クラスに _coupons としてカプセル化(Ch9)
4 マジックナンバー 9999999 可読性 MAX_USAGE_COUNT = 9_999_999 と名前付き定数化(Ch10)
5 is_active == True の冗長な比較 イディオム if c.status == CouponStatus.ACTIVE: に変更
6 金額計算に float を使用 精度問題 Decimal に変更(EC・金融系では必須)

リファクタリング後の設計図

CouponStatus StrEnum(型安全) ACTIVE = "active" INACTIVE = "inactive" bool ではなく型で表現 Coupon @dataclass(slots=True) 値オブジェクト code: str discount_rate: int min_order_amount: int status: CouponStatus expires_at: datetime used_count: int = 0 is_usable(order_amount) → bool calc_discount_amount(amt) → Decimal uses CouponRepository 集約ルート(カプセル化) _coupons: dict[str, Coupon] add(coupon) → None apply(code, amt) → Decimal|None get_active_coupons() → list deactivate_expired(now) → int グローバル変数を廃止 コレクションをカプセル化(Ch9) 副作用の範囲が明確 Repository パターン has 定数 MAX_USAGE_COUNT = 9_999_999 Before: グローバル COUPON_LIST、dict、5段ネスト if、マジックナンバー、float 計算 After: CouponRepository カプセル化、dataclass 値オブジェクト、早期 return、定数、Decimal

模範解答

"""販促クーポン管理モジュール(改善版)。

グローバル状態を排除し、Coupon を値オブジェクト、CouponRepository を
集約ルートとしてカプセル化している。
"""

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime
from enum import StrEnum
from decimal import Decimal


# --- 定数 ---
MAX_USAGE_COUNT: int = 9_999_999
"""クーポンの最大利用回数(実質的な上限なし)。"""


class CouponStatus(StrEnum):
    """クーポンのアクティブ状態を型安全に表現する列挙型。"""
    ACTIVE = "active"
    INACTIVE = "inactive"


@dataclass(slots=True)
class Coupon:
    """クーポンを表す値オブジェクト。

    Attributes:
        code: クーポンコード(一意)。
        discount_rate: 割引率(0〜100 の整数 %)。
        min_order_amount: 適用可能な最低注文金額(円)。
        status: クーポンの有効状態。
        expires_at: 有効期限。
        used_count: 利用済み回数。
    """
    code: str
    discount_rate: int
    min_order_amount: int
    status: CouponStatus
    expires_at: datetime
    used_count: int = field(default=0)

    def is_usable(self, order_amount: int) -> bool:
        """クーポンが指定注文金額に適用可能かを判定する。

        早期 return で逆条件を先に弾くことでネストを排除している(Ch8)。
        """
        if self.status != CouponStatus.ACTIVE:
            return False
        if self.discount_rate <= 0:
            return False
        if order_amount < self.min_order_amount:
            return False
        if self.used_count >= MAX_USAGE_COUNT:
            return False
        return True

    def calc_discount_amount(self, order_amount: int) -> Decimal:
        """割引額を計算して返す(純粋関数・副作用なし)。"""
        return Decimal(order_amount) * Decimal(self.discount_rate) / Decimal(100)


class CouponRepository:
    """クーポンの永続化・検索・状態変更を担うリポジトリ。

    コレクション自体をカプセル化している(Ch9: コレクションのカプセル化)。
    """

    def __init__(self) -> None:
        # コードをキーにした辞書で O(1) 検索を実現
        self._coupons: dict[str, Coupon] = {}

    def add(self, coupon: Coupon) -> None:
        """クーポンを登録する。"""
        if coupon.code in self._coupons:
            raise ValueError(f"Coupon code '{coupon.code}' already exists.")
        self._coupons[coupon.code] = coupon

    def apply(self, code: str, order_amount: int) -> Decimal | None:
        """クーポンを注文に適用し、割引後金額を返す。"""
        coupon = self._coupons.get(code)
        if coupon is None:
            return None
        if not coupon.is_usable(order_amount):
            return None

        coupon.used_count += 1
        discount = coupon.calc_discount_amount(order_amount)
        return Decimal(order_amount) - discount

    def get_active_coupons(self) -> list[Coupon]:
        """有効なクーポン一覧を返す(内包表記)。"""
        return [c for c in self._coupons.values() if c.status == CouponStatus.ACTIVE]

    def deactivate_expired(self, now: datetime) -> int:
        """有効期限切れのクーポンを無効化し、無効化した件数を返す。"""
        count = 0
        for coupon in self._coupons.values():
            if coupon.expires_at < now and coupon.status == CouponStatus.ACTIVE:
                coupon.status = CouponStatus.INACTIVE
                count += 1
        return count

ポイント解説

1 マジックナンバーの排除(Ch10: 設計の悪魔)
9999999MAX_USAGE_COUNT = 9_999_999 に置き換え。数値の意味が名前から伝わるようになった。
2 早期 return でネスト平坦化(Ch8: 条件分岐)
apply_coupon の5段ネストを is_usable の早期 return に分解。各ガード節が独立して読みやすい。
3 コレクションのカプセル化(Ch9)
グローバル COUPON_LISTCouponRepository クラスの _coupons に隠蔽。外部から直接リストを操作できなくなり、副作用の範囲が明確になった。
4 値オブジェクト化(Ch3: カプセル化)
クーポンを dict から @dataclass(slots=True) に変更。型チェックが静的解析で可能になり、typo によるキーエラーがなくなった。
5 StrEnum による型安全(Python 3.11+)
is_active: boolstatus: CouponStatus に変更。文字列比較でありながら IDE の補完と型チェックの両方が効く。
6 Decimal による金額計算
浮動小数点誤差を避けるため Decimal を使用。EC・金融系では必須の判断。

Before vs After 比較

観点BeforeAfter
データ表現dict(型チェック不可)@dataclass(slots=True)(型安全)
状態管理グローバル COUPON_LISTCouponRepository._coupons(カプセル化)
条件分岐5段ネスト if早期 return(フラット)
数値リテラル9999999(マジックナンバー)MAX_USAGE_COUNT = 9_999_999
金額計算float(誤差あり)Decimal(精度保証)
検索効率線形スキャン O(n)辞書検索 O(1)

実務への応用

  • Argo Workflowsのタイムゾーン注入: deactivate_expired(now=datetime.now(tz=JST)) をArgo Workflowsの定期ジョブとして実装する際、now を引数で受け取る設計にしておくとユニットテストで時刻を差し込める。
  • Repository パターンの実装切り替え: CouponRepository_coupons を実際の実装では BigQuery や Cloud Firestore に差し替えることで、テスト時はインメモリ、本番は外部ストレージと切り替えられる。
  • 型チェックツールによる安全性向上: apply の返り値を Decimal | None にしたことで、mypy/pyright が「None チェックなしに計算している」コードを検出してくれる。

次のステップ

発展問題: CouponRepository にユニットテスト(pytest)を書く。deactivate_expired で時刻を freezegun でモックし、境界値テストを追加する。
  • 参考: 「良いコード・悪いコードで学ぶ設計入門」改訂新版 Ch8・Ch9・Ch10
  • 参考: PEP 663 (StrEnum)、Python decimal 標準ライブラリ

今日のまとめ

グローバルなミュータブルコレクションは「誰でも触れる状態」を生み出し、バグの温床になる。 カプセル化・早期 return・値オブジェクト化の3手を組み合わせることで、テストしやすく変更に強いコードへ変貌する。

自己評価

自分の回答

気づき・メモ