コーディング設計 — カプセル化 × 値オブジェクト

2026-04-05 (Day 1) A: コーディング設計 ★★★☆☆ 「良いコード・悪いコードで学ぶ設計入門」Ch3

概要

🏗️

完全コンストラクタ(Ch3.4.1)

コンストラクタで確実に正常値を保証する。生焼けオブジェクト(Coupon(-100, -500, -1))を生成できない設計。

💎

値オブジェクト(Ch3.4.2)

discount_amountmin_order_amount を型として定義する。frozen=True で不変化。

📣

Tell, Don't Ask(Ch5/13)

外から状態を聞いてロジックを実行するのではなく、クラス自身に命じる。バリデーション・適用ロジックをクラス内に収める。

🔒

凝集度の向上

apply_coupon() / is_valid_coupon() は外部関数ではなく Coupon のメソッドとして移動する。

問題

以下の「悪いコード」があります。EC販促システムで使われるクーポン管理クラスです。

リファクタリング要件

  1. 問題点を日本語で列挙すること(番号付き)
  2. 書籍第3章の原則(完全コンストラクタ・値オブジェクト・ロジックをデータ保持側に寄せる)を適用
  3. 改善後コードに Google スタイル docstring を付けること
  4. 実行例(正常系・異常系)と期待出力を示すこと
  5. 適用した設計パターン名と書籍の対応章を明記すること
制約: Python 3.12+ / 標準ライブラリのみ(dataclasses 使用可)/ 既存の動作は変えない

悪いコード (Before)

このコードには 5つの設計上の問題 が隠れています。見つけてみてください。
# bad_coupon.py

class Coupon:
    def __init__(self, coupon_code, discount_amount, min_order_amount):
        self.coupon_code = coupon_code
        self.discount_amount = discount_amount   # int(円)
        self.min_order_amount = min_order_amount # int(円)
        self.used = False


def apply_coupon(coupon, order_amount):
    if coupon.discount_amount < 0:
        raise Exception("割引額が不正")
    if order_amount < coupon.min_order_amount:
        raise Exception("最小注文金額未満")
    if coupon.used:
        raise Exception("使用済みクーポン")
    coupon.used = True
    return order_amount - coupon.discount_amount


def is_valid_coupon(coupon):
    return (
        coupon.discount_amount > 0
        and coupon.min_order_amount >= 0
        and not coupon.used
    )

ヒント(段階的開示)

ヒント1 — 方向性
Coupon クラスはデータを保持するだけで、バリデーションや適用ロジックが外部関数に分散している。 「クラスが自分自身でドメインモデルの完全性を保証する」という第3章の原則を思い出そう。 どのロジックがどのクラスに属すべきかを考えることがカプセル化の第一歩。
ヒント2 — アプローチ(値オブジェクト設計)
  • discount_amount(int)をそのまま扱うのは「プリミティブ型執着」(第5章とも関連)。DiscountAmount / MinOrderAmount という専用クラスを作り、バリデーションをコンストラクタ内で行う
  • @dataclass(frozen=True) を使うと不変な値オブジェクトを簡潔に表現できる
  • apply_coupon() / is_valid_coupon()Coupon のメソッドとして移動する(Tell, Don't Ask)
ヒント3 — 目指す骨格
@dataclass(frozen=True)
class DiscountAmount:
    value: int

    def __post_init__(self) -> None:
        if self.value < 1:
            raise ValueError(...)

@dataclass
class Coupon:
    code: str
    discount_amount: DiscountAmount
    min_order_amount: MinOrderAmount
    _used: bool = field(default=False, init=False, repr=False)

    def is_applicable(self, order_amount: int) -> bool:
        ...

    def apply(self, order_amount: int) -> int:
        ...

問題点分析

#問題点分類影響改善方法
1 コンストラクタでバリデーションをしていない 生焼けオブジェクト Coupon(-100, -500, -1) のような不正値が生成できる __post_init__ でバリデーション
2 データとロジックが分離している 低凝集度 外部関数 apply_coupon()Coupon の内部状態を直接操作する Coupon.apply() としてメソッド化
3 プリミティブ型執着 型安全性 引数を逆順に渡しても検知できない DiscountAmount / MinOrderAmount 値オブジェクト化
4 外部から状態を直接変更できる カプセル化破壊 coupon.used = True を外から書き換えられる field(init=False) で内部状態に限定
5 メソッド名が「動詞+目的語」形式 命名 apply_coupon(coupon) は本来 coupon.apply() と書くべき オブジェクト自身にメソッドを持たせる

設計構造図 — 値オブジェクト × カプセル化

DiscountAmount frozen=True 不変値オブジェクト value: int (>= 1) __post_init__: value < 1 → ValueError ❄️ 変更不可 MinOrderAmount frozen=True 不変値オブジェクト value: int (>= 0) Coupon Fields code: str discount_amount: DiscountAmount min_order_amount: MinOrderAmount _used: bool (init=False) Methods: is_applicable() / apply() Tell, Don't Ask ✗ 聞く(Ask) if is_valid_coupon(c): apply_coupon(c, amt) ✓ 命じる(Tell) c.apply(order_amount)

模範解答

from dataclasses import dataclass, field


@dataclass(frozen=True)
class DiscountAmount:
    """割引額を表す値オブジェクト(不変)。

    Args:
        value: 割引額(円)。1以上の整数。

    Raises:
        ValueError: value が 1 未満の場合。
    """
    value: int

    def __post_init__(self) -> None:
        if self.value < 1:
            raise ValueError(f"割引額は1円以上が必要です: {self.value}")


@dataclass(frozen=True)
class MinOrderAmount:
    """最小注文金額を表す値オブジェクト(不変)。"""
    value: int

    def __post_init__(self) -> None:
        if self.value < 0:
            raise ValueError(f"最小注文金額は0円以上が必要です: {self.value}")


@dataclass
class Coupon:
    """ECサイトの割引クーポン。

    完全コンストラクタパターン: コンストラクタで完全性を保証。
    Tell, Don't Ask: 割引適用ロジックをクーポン自身が担う。

    Args:
        code: クーポンコード。空文字・空白不可。
        discount_amount: 割引額の値オブジェクト。
        min_order_amount: 適用最小注文金額の値オブジェクト。
    """

    code: str
    discount_amount: DiscountAmount
    min_order_amount: MinOrderAmount
    _used: bool = field(default=False, init=False, repr=False)

    def __post_init__(self) -> None:
        if not self.code or not self.code.strip():
            raise ValueError("クーポンコードは空にできません")

    def is_applicable(self, order_amount: int) -> bool:
        """このクーポンが指定注文金額に適用可能かを返す。"""
        return not self._used and order_amount >= self.min_order_amount.value

    def apply(self, order_amount: int) -> int:
        """クーポンを適用し、割引後の金額を返す。

        Raises:
            ValueError: 使用済み、または最小金額未満の場合。
        """
        if self._used:
            raise ValueError(f"クーポン '{self.code}' は使用済みです")
        if order_amount < self.min_order_amount.value:
            raise ValueError(
                f"注文金額 {order_amount}円 が最小注文金額 {self.min_order_amount.value}円 未満です"
            )
        self._used = True
        return order_amount - self.discount_amount.value
# --- 正常系 ---
discount = DiscountAmount(500)
min_order = MinOrderAmount(3000)
coupon = Coupon("SALE2026", discount, min_order)

print(coupon.is_applicable(5000))   # True
print(coupon.apply(5000))            # 4500  ← 5000 - 500
print(coupon.is_applicable(5000))   # False (使用済み)

# --- 異常系: 値オブジェクト生成時のバリデーション ---
try:
    DiscountAmount(0)
except ValueError as e:
    print(e)  # 割引額は1円以上が必要です: 0

# --- 異常系: 最小金額未満での適用 ---
coupon2 = Coupon("WINTER", DiscountAmount(1000), MinOrderAmount(5000))
try:
    coupon2.apply(3000)
except ValueError as e:
    print(e)  # 注文金額 3000円 が最小注文金額 5000円 未満です

# --- 異常系: 使用済みクーポンの再適用 ---
coupon3 = Coupon("SPRING", DiscountAmount(300), MinOrderAmount(1000))
coupon3.apply(2000)  # 初回: 1700
try:
    coupon3.apply(2000)
except ValueError as e:
    print(e)  # クーポン 'SPRING' は使用済みです

適用した設計パターン

パターン書籍の章内容
完全コンストラクタ 第3章 3.4.1 __post_init__ で不正値を即座にはじき、生焼けオブジェクトをなくす
値オブジェクト 第3章 3.4.2 DiscountAmountMinOrderAmount を専用型に昇格。frozen=True で不変化
Tell, Don't Ask 第5章 5.7.1 / 第13章 外部から状態を聞いてロジックを組む代わりに、クーポン自身に命じる(apply()

ポイント解説

1 完全コンストラクタ(3.4.1)
__post_init__ でバリデーションを行うことで、「正常に構築されたオブジェクトは常に正常」を保証する。コンストラクタ後にバリデーションコードを別途呼ぶ設計は脆弱。
2 値オブジェクト(3.4.2)
int のまま渡すと Coupon(code, min_order, discount) と引数を逆に渡しても型エラーにならない。DiscountAmount / MinOrderAmount という専用型にすることで、型システムが引数の取り違えを防ぐ。
3 field(init=False)
_usedinit=False にすることで、外部からコンストラクタ経由での初期値上書きを防ぎ、内部状態を完全にカプセル化する。
4 メソッド名は動詞1語(第13章)
apply_coupon(coupon)coupon.apply() へ。「動詞+目的語」形式は「このメソッドが本来どのクラスに属すべきか」を見直すサインになる。

実務への応用

EC販促システム(MOps)での活用例
  • クーポン管理: DiscountAmount を値オブジェクトにすることで、BigQueryへのエクスポート時も .value で一貫した数値型として扱える
  • Argo Workflows タスク: apply() のように「操作はオブジェクト自身に委譲する」設計にすると、Workflow の各ステップが「何を実行するか」だけを指示すればよくなる
  • バリデーション基盤: Cloud Run API のリクエストバリデーションも「エンドポイントに入った瞬間に値オブジェクトへ変換 → 以降は型の保証に頼る」設計にするとビジネスロジック層でのガード節が不要になる

次のステップ

発展問題1: Coupon@dataclass(frozen=True) で完全に不変にするには apply() をどう設計し直すか?(dataclasses.replace() の活用)
発展問題2: 複数種類のクーポン(金額割引・パーセント割引・送料無料)を Protocol で統一インターフェース化するとどう変わるか?(ストラテジパターン: 第8章)

今日のまとめ

データとロジックを同じクラスに閉じ込め、コンストラクタで正常値を保証するのがカプセル化の本質。

int の代わりに DiscountAmount という型を使うだけで、型システムが引数の取り違えを防ぎ、バリデーション漏れをなくし、コードの意図が格段に読みやすくなる。

自己評価

自分の回答

気づき・メモ