A コーディング — 完全コンストラクタ・値オブジェクト・不変(キャンペーン予算 CampaignBudget Bad→Good Ch3)

2026-08-17 (Day 134) 月曜 コーディング ★★★★☆ Python 3.12 / dataclass(frozen) 良いコード設計入門 Ch3(カプセル化)2周目2問目

概要

🏗️

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

生成された瞬間から常にすべての不変条件を満たしている状態でしか、インスタンスを生成できないようにする。「生成後にsetterで埋める」という2段階初期化を許さない。

💴

値オブジェクト(Ch3)

「金額」のような意味のあるまとまりをプリミティブ型(int)のまま扱わず、専用のクラスに閉じ込める。検証ロジックが1箇所に集約され、呼び出し側での検証漏れがなくなる。

🔒

不変(Ch3)

生成後はフィールドを一切変更できないようにする(frozen)。状態を変える唯一の手段を「新しいインスタンスを返すメソッド」に限定し、変更箇所をコード上で追跡可能にする。

💥

実際に起きた事故

広告配信プラットフォームのwebhookが異常な負の値を返し、spent_yenが実態と乖離。予算270%超過までキャンペーンが停止されず、広告費が想定外に発生した。

問題

EC企業の販促(MOps)システムには、広告配信キャンペーンの予算消化を追跡する CampaignBudget クラスが本番稼働している。

先週起きたインシデント: 広告配信プラットフォームからのwebhookレスポンスに異常な負の巨大値が混入し、spent_yen が実態と大きく乖離した状態のままキャンペーンが停止されずに走り続け、予算を270%超過して広告費が想定外に発生した。原因調査では「CampaignBudget が生成されてから set_total が呼ばれるまでの間、予算未設定のまま消化額だけ加算できる状態が存在していたこと」「spent_yen を外部コードが直接書き換えられ、どこで・なぜ変更されたかの追跡ができないこと」が問題として指摘された。

「良いコード・悪いコードで学ぶ設計入門」第3章(カプセル化:完全コンストラクタ、値オブジェクト、不変)の考え方を使って、CampaignBudget を再設計してください。

制約・前提条件

  • Python 3.12+
  • 金額(円)を表す値オブジェクト Money を導入すること。0円未満の Money は生成できないようにする
  • CampaignBudget は「予算」「消化額」が揃っていない状態のインスタンスを一切生成できないようにする(完全コンストラクタ)
  • CampaignBudget は生成後にフィールドを直接変更できない(不変)。支出を記録する操作は、新しいインスタンスを返すメソッドとして提供する
  • 消化額が予算を超える状態(生成時・支出記録時のいずれも)を許可してはならず、超える場合は専用の例外を送出すること
  • 負の金額を「支出」として記録しようとした場合の扱いも設計に含めること(Money の不変条件により自然に防げる設計が望ましい)
期待する回答形式: 問題点の列挙(番号付き)+ 改善後コード(docstring・インラインコメント・名前付き定数含む)+ 実行例(input→output)+ 適用した設計パターン名と対応章

悪いコード (Before)

このコードには 7つの問題 が隠れています。見つけてみてください。
bad_budget.py — 2段階初期化・可変属性・検証なし
class CampaignBudget:
    def __init__(self):
        self.total_yen = 0
        self.spent_yen = 0

    def set_total(self, total_yen):
        self.total_yen = total_yen

    def add_spend(self, amount):
        self.spent_yen += amount

# --- 呼び出し側(複数箇所に散らばっている) ---
budget = CampaignBudget()
budget.set_total(500_000)
budget.add_spend(200_000)

# 別の処理: 広告配信APIのレスポンスをそのまま加算
budget.add_spend(webhook_payload["cost_delta"])  # マイナス値が来ることもある

# さらに別の処理: 手動での予算調整
budget.spent_yen -= 50_000  # 属性を直接いじって「消化額を戻す」
問題点サマリー(7点)
12段階初期化(Ch3)__init__()直後はtotal_yen=0のまま「予算未設定」の不正状態で存在でき、set_totalを呼ぶまでその状態が続く
2予算未確定のまま消化額を加算できるset_totalを呼び忘れたコードパスでもadd_spendは素通りする
3予算超過チェックが皆無spent_yentotal_yenを超えても例外にならない
4負の値がそのまま加算される — webhookの異常値(巨大な負の数)を検証せず+=している
5属性が外部から直接書き換え可能budget.spent_yen -= 50_000のような改ざんを止める手段がない
6プリミティブ型執着(Ch5) — 金額を素のintで扱い、検証ロジックが呼び出し側ごとに散らばるリスクを抱える
7変更の追跡不能 — 状態を変える手段が統一されておらず、「いつ・どこで消化額が変わったか」をコード上で追えない

ヒント(段階的開示)

ヒント1 — 方向性
total_yenspent_yen を別々の int 属性として持たせている限り、「生成直後は0円のまま」「あとから set_total で埋める」という2段階初期化を防げません。またこの2段階初期化こそが、インシデントで起きた「予算未設定のまま消化額だけ加算できる」不正状態の直接の原因です。金額そのものと、予算+消化額の組み合わせ、それぞれを「生成された瞬間から常に正しい状態である」ものとして扱えないか考えてみてください。
ヒント2 — アプローチ
  • Money値オブジェクト: 円金額をラップし、生成時の検証で0円未満を拒否するfrozenなdataclass。加算・減算は新しいMoneyを返すメソッドとして実装し、既存インスタンスを書き換えない
  • CampaignBudget: total: Moneyspent: Moneyを持つfrozenなdataclass。生成時(__post_init__)にspent <= totalを検証し、破れば例外を送出する
  • 支出を記録するrecord_spend(amount: Money) -> CampaignBudgetメソッドは、自分自身を書き換えず「支出反映後の新しいCampaignBudget」を返す
これにより「マイナスの支出」はMoney生成時点で、「予算超過」はCampaignBudget生成時点で、それぞれ機械的に弾かれます。
ヒント3 — コードの骨格
from __future__ import annotations
from dataclasses import dataclass

MIN_YEN = 0

class InvalidMoneyError(ValueError): ...
class BudgetExceededError(ValueError): ...

@dataclass(frozen=True, slots=True)
class Money:
    yen: int

    def __post_init__(self) -> None:
        if self.yen < MIN_YEN:
            raise InvalidMoneyError(...)

    def add(self, other: Money) -> Money:
        return Money(...)

    def subtract(self, other: Money) -> Money:
        return Money(...)  # 負になる場合はMoney側の検証で自然にエラーになる

    def is_greater_than(self, other: Money) -> bool:
        return self.yen > other.yen

@dataclass(frozen=True, slots=True)
class CampaignBudget:
    total: Money
    spent: Money

    def __post_init__(self) -> None:
        if self.spent.is_greater_than(self.total):
            raise BudgetExceededError(...)

    @property
    def remaining(self) -> Money:
        return ...

    def record_spend(self, amount: Money) -> CampaignBudget:
        new_spent = ...
        return CampaignBudget(total=..., spent=...)

問題点分析(7点)

#問題点分類改善方法
12段階初期化で未設定状態が生成可能完全コンストラクタ違反 Ch3生成時にtotal/spent両方を必須引数化
2予算未確定のまま消化額を加算できる不正状態の存在 Ch3__post_init__でspent<=totalを検証
3予算超過チェックが皆無不変条件の欠如 Ch3BudgetExceededErrorを送出
4負の値をそのまま加算検証漏れMoney生成時に0円未満を拒否
5属性を外部から直接書き換え可能可変性 Ch3frozen dataclass化
6金額を素のintで扱うプリミティブ型執着 Ch5Money値オブジェクトに集約
7変更手段が統一されておらず追跡不能不変 Ch3record_spend()経由のみに限定

状態遷移の比較(SVG)— 可変オブジェクト vs 値オブジェクト

Bad: 同一インスタンスを段階的に書き換える __init__() → total=0, spent=0(不正状態で生成完了) set_total(500000) — 呼び忘れうる add_spend(webhookの負の異常値) — 検証なし 結果: spent_yenが実態と乖離 → 予算270%超過で発覚 Good: 新しいインスタンスを生成し続ける(Ch3) CampaignBudget(total=Money(500000), spent=Money(0)) record_spend(Money(200000)) → 新インスタンス生成 record_spend(webhookの負の値) → Money生成時点で例外 結果: 不正状態のインスタンスは1つも生成されない 共通の問い: 「このインスタンスは今この瞬間、常に正しい状態か?」 Badは「正しい状態になるまでの過渡期」が存在する。Goodは生成に失敗するか、生成できた時点で必ず正しい。

模範解答

"""販促キャンペーンの予算管理モジュール。

良いコード・悪いコードで学ぶ設計入門(改訂新版)
  Ch3: カプセル化 — 完全コンストラクタ・値オブジェクト・不変

金額(Money)とキャンペーン予算(CampaignBudget)をいずれも不正な状態で
生成できない値オブジェクトとして実装する。
"""
from __future__ import annotations

from dataclasses import dataclass

MIN_YEN = 0  # 金額の下限(これ未満の円は不正な値として扱う)


class InvalidMoneyError(ValueError):
    """金額が不正な値(0円未満)である場合に送出する例外。"""


class BudgetExceededError(ValueError):
    """キャンペーン予算の消化額が予算総額を超える場合に送出する例外。"""


@dataclass(frozen=True, slots=True)
class Money:
    """日本円の金額を表す値オブジェクト。

    生成された瞬間から必ず0円以上であることが保証される(完全コンストラクタ)。
    インスタンスは不変であり、加減算の結果は常に新しいインスタンスとして返す。

    Attributes:
        yen: 円単位の金額。0以上の整数のみ許可する。

    Raises:
        InvalidMoneyError: yenがMIN_YEN未満の場合。
    """
    yen: int

    def __post_init__(self) -> None:
        if self.yen < MIN_YEN:
            raise InvalidMoneyError(
                f"金額は{MIN_YEN}円以上である必要があります: {self.yen}円"
            )

    def add(self, other: Money) -> Money:
        """他のMoneyを加算した新しいMoneyを返す。

        Args:
            other: 加算する金額。

        Returns:
            加算後の新しいMoneyインスタンス。
        """
        return Money(self.yen + other.yen)

    def subtract(self, other: Money) -> Money:
        """他のMoneyを減算した新しいMoneyを返す。

        Args:
            other: 減算する金額。

        Returns:
            減算後の新しいMoneyインスタンス。

        Raises:
            InvalidMoneyError: 減算結果が0円未満になる場合
                (Moneyの__post_init__による検証を通じて送出される)。
        """
        return Money(self.yen - other.yen)

    def is_greater_than(self, other: Money) -> bool:
        """自身がotherより大きい金額かどうかを返す。"""
        return self.yen > other.yen


@dataclass(frozen=True, slots=True)
class CampaignBudget:
    """販促キャンペーンの予算と消化額を表す値オブジェクト。

    「予算」と「消化額」が揃っていない状態のインスタンスは生成できず
    (完全コンストラクタ)、消化額が予算を超える組み合わせも生成できない。
    生成後は不変であり、支出の記録はrecord_spend()が新しいインスタンスを
    返すことで表現する(外部からの直接書き換えを禁止)。

    Attributes:
        total: キャンペーンの総予算。
        spent: これまでの消化額。

    Raises:
        BudgetExceededError: spentがtotalを超えている場合。
    """
    total: Money
    spent: Money

    def __post_init__(self) -> None:
        if self.spent.is_greater_than(self.total):
            raise BudgetExceededError(
                f"消化額が予算を超えています: "
                f"spent={self.spent.yen}円, total={self.total.yen}円"
            )

    @property
    def remaining(self) -> Money:
        """残予算を返す。"""
        return self.total.subtract(self.spent)

    def record_spend(self, amount: Money) -> CampaignBudget:
        """支出を記録した新しいCampaignBudgetを返す。

        自身のフィールドは一切変更せず、支出反映後の新しいインスタンスを
        生成して返す。これにより「いつ・どの呼び出しで消化額が変わったか」が
        コード上のメソッド呼び出しに必ず対応し、属性への直接代入による
        追跡不能な変更を構造的に防ぐ。

        Args:
            amount: 今回記録する支出額(0円未満はMoneyの生成時点で拒否される)。

        Returns:
            支出反映後の新しいCampaignBudgetインスタンス。

        Raises:
            BudgetExceededError: 記録後の消化額が予算を超える場合。
        """
        new_spent = self.spent.add(amount)
        return CampaignBudget(total=self.total, spent=new_spent)
budget = CampaignBudget(total=Money(500_000), spent=Money(0))
budget = budget.record_spend(Money(200_000))
print(budget.spent.yen, budget.remaining.yen)
# => 200000 300000

try:
    budget.record_spend(Money(400_000))  # 200,000 + 400,000 = 600,000 > 500,000
except BudgetExceededError as e:
    print(e)
    # => 消化額が予算を超えています: spent=600000円, total=500000円

try:
    Money(-50_000)  # webhookの異常なcost_deltaに相当
except InvalidMoneyError as e:
    print(e)
    # => 金額は0円以上である必要があります: -50000円
ポイント適用した設計原則書籍対応章
Money/CampaignBudgetとも生成時に不変条件を検証完全コンストラクタCh3
円金額をintのままでなくMoneyに閉じ込める値オブジェクトCh3
frozen=Trueで直接代入を禁止、record_spend()が新インスタンスを返す不変Ch3
金額の検証・加減算ロジックを1箇所に集約プリミティブ型執着の回避Ch5
Before(Bad)After(Good)
__init__()だけでtotal=0の不正状態が生成可能CampaignBudget(total=..., spent=...)のみで生成、不正なら即例外
set_totalをいつでも呼べる(生成後の再設定)フィールドの再設定手段自体が存在しない(frozen)
add_spendに上限・下限チェックなしrecord_spendが予算超過を検証、負の値はMoneyが拒否
budget.spent_yen -= 50_000で外部から改ざん可能直接代入は例外(frozen dataclass)

ポイント解説

1完全コンストラクタが「過渡期の不正状態」を消す(Ch3) — 旧コードの「__init__() → 後からset_total()」という2段階初期化を排除し、インスタンスが存在する限り常に不変条件を満たすことを保証する。これがインシデントの根本原因(予算未設定のまま消化額だけ存在する状態)を構造的に消している。
2値オブジェクトが検証ロジックの重複を防ぐ(Ch3) — 円金額という「意味のあるまとまり」をintのまま扱わずMoneyクラスに閉じ込めることで、「0円以上」という不変条件・加減算のルールを1箇所に集約した。プリミティブ型執着(第5章)を避け、金額に関する検証漏れが呼び出し側ごとに再発するリスクをなくす。
3不変が「変更の痕跡」を保証する(Ch3)frozen=TrueによりCampaignBudget.spentへの直接代入(budget.spent_yen -= 50_000に相当する操作)をPythonレベルで禁止した。状態を変える唯一の手段がrecord_spend()という名前の付いたメソッド呼び出しになるため、「いつ・なぜ消化額が変わったか」が必ずコード上に痕跡を残す。

実務への応用

MOpsの予算管理バッチ(Argo Workflows / Cloud Run上で広告配信APIのwebhookを受け続けるサービス)では、外部APIのレスポンス異常値がそのまま内部状態を汚染するリスクが常にある。Moneyのような値オブジェクトで「そもそも不正な値はオブジェクトとして存在できない」設計にしておけば、バリデーションをif文で都度書く必要がなくなり、レビュー時にも「このクラスのインスタンスが存在する=条件を満たしている」と読める。

楽観的ロックとの相性: record_spend()が新しいインスタンスを返す設計は、DB更新時の楽観的ロック(更新前のCampaignBudgetを条件にUPDATEし、行数0なら競合とみなしてリトライ)とも相性がよく、複数ワーカーが並行して同じキャンペーンの支出を記録するケースの土台になる。

今日のまとめ

「予算未設定のまま消化額だけ加算できる」「属性を外部から直接書き換えられる」という2つの不正状態は、どちらも「生成後に段階的に埋められる可変オブジェクト」という設計そのものに起因しており、完全コンストラクタ・値オブジェクト・不変という3つの原則をセットで適用することで構造的に排除できる。

次のステップ

  • 発展問題1: 複数の広告チャネル(Google/Meta/LINE)ごとのCampaignBudgetを1つのCampaignBudgetPoolとして集約管理し、「チャネル別予算の合計がキャンペーン全体予算を超えないこと」という上位の不変条件を、各CampaignBudgetの不変性を保ったままどう表現するか設計する
  • 発展問題2: record_spend()が同時に複数のワーカーから呼ばれるケースを想定し、Cloud SQL上の永続化と組み合わせた楽観的ロック(バージョン番号 or total/spentを条件にしたUPDATE)を実装する
  • 参考: 「良いコード・悪いコードで学ぶ設計入門」第3章(カプセル化)、第5章(実践カプセル化:プリミティブ型執着)

自己評価(あとで記入)