A コーディング — 意味不明な命名・深いネスト・データクラス問題(キャンペーン割引 CampaignDiscountData Bad→Good Ch1-2)

2026-08-31 (Day 113) 月曜 コーディング ★★★★☆ Python 3.12 / StrEnum / dataclass(frozen, slots) 良いコード設計入門 Ch1-2(悪しき構造・設計の初歩)

概要

📦

データクラス問題(Ch1-2)

公開フィールドだけを持ち、振る舞い(判断ロジック)を一切持たない「データの入れ物」。判断材料を持つオブジェクト自身に判断させず外部に漏らすと、参照箇所が増えるたびにロジックがコピペされる。

🏷️

意味不明な命名(Ch1-2)

Mgr・o, c, d, t, r, f のような何を表すか読み取れない名前は、コードの意図を読み手の推測に依存させ、レビューでの見逃しを増やす。

🕳️

深いネスト(Ch1-2)

if の中に if を重ねる4段ネストは、条件の組み合わせを目で追うコストを上げ、「1箇所だけ条件を書き忘れる」というミスを誘発する。

💥

実際に起きた事故

カートのプレビュー画面では10%引きと表示されたのに、注文確定では割引なしで決済される問い合わせが多発。原因はCartPreviewServiceだけend_at(有効期限)チェックが実装から漏れていたこと。

問題

EC企業 MOps 販促システムチームでは、注文にキャンペーン割引を適用する処理が3箇所に分散して実装されている。

  • Mgr(注文確定時の最終金額計算)
  • CartPreviewService(カート画面のプレビュー金額計算)
  • NotificationService(Slackへの割引適用通知)
先日起きたインシデント: カートのプレビュー画面では10%引きと表示されたのに、実際に注文を確定すると割引が適用されず定価で決済されるという問い合わせが複数件発生した。調査の結果、対象キャンペーンは前日に終了しており、Mgr.calc(注文確定処理)では有効期限(end_at)チェックが行われて正しく割引なしになっていたが、CartPreviewService.preview(カート表示処理)では有効期限チェックが実装から抜け落ちており、期限切れキャンペーンの割引がそのまま表示されていたことが原因だった。

「良いコード・悪いコードで学ぶ設計入門」第1-2章(悪しき構造・設計の初歩:意味不明な命名、ネスト、データクラス問題)の考え方を使って、根本原因を構造の問題として説明した上でリファクタリングしてください。

制約・前提条件

  • Python 3.12+ を前提とする(StrEnum, match文, dataclass(slots=True) などモダン記法を使ってよい)
  • CampaignDiscountData は公開フィールドのみを持つデータの入れ物であり、「対象かどうか」「いくら割引するか」の判断ロジックが3箇所に重複実装されている、という構造上の欠陥を名指しで指摘すること
  • リファクタリング後は、今回のバグ(CartPreviewServiceだけend_atチェックが漏れる)が構造的に再発しえないことを説明すること
期待する回答形式: 問題点の列挙(番号付き)+ 改善後コード(docstring・インラインコメント・名前付き定数含む)+ 実行例(input→output)+ 適用した設計パターン名と対応章

悪いコード (Before)

このコードには 7つの問題 が隠れています。見つけてみてください。
bad_discount.py — 判定ロジックが3箇所に重複した「データの入れ物」
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal


@dataclass
class CampaignDiscountData:
    id: str
    type: str  # "PERCENT" or "FIXED"
    value: Decimal
    start_at: datetime
    end_at: datetime
    target_rank: str  # "ALL" / "GOLD" / "PLATINUM"
    is_active: bool


class Mgr:
    """キャンペーン割引を注文に適用する。"""

    def calc(self, o, c, d):
        # o: order, c: customer, d: CampaignDiscountData
        t = Decimal("0")
        if d is not None:
            if d.is_active:
                if d.end_at > datetime.now():
                    if d.target_rank == "ALL" or d.target_rank == c.rank:
                        if d.type == "PERCENT":
                            t = o.subtotal * (d.value / Decimal("100"))
                        else:
                            if d.type == "FIXED":
                                t = d.value
        return o.subtotal - t


class CartPreviewService:
    """カート画面のプレビュー金額を計算する。"""

    def preview(self, o, c, d):
        r = Decimal("0")
        if d is not None:
            if d.is_active:
                if d.target_rank == "ALL" or d.target_rank == c.rank:
                    if d.type == "PERCENT":
                        r = o.subtotal * (d.value / Decimal("100"))
                    else:
                        if d.type == "FIXED":
                            r = d.value
        # NOTE: end_at のチェックが Mgr.calc と食い違っている
        return o.subtotal - r


class NotificationService:
    """Slackへの割引適用通知を送る。"""

    def build_message(self, o, c, d):
        f = Decimal("0")
        if d is not None:
            if d.is_active:
                if d.end_at > datetime.now():
                    if d.target_rank == "ALL" or d.target_rank == c.rank:
                        if d.type == "PERCENT":
                            f = o.subtotal * (d.value / Decimal("100"))
                        else:
                            if d.type == "FIXED":
                                f = d.value
        return f"割引 {f}円 を適用しました(注文ID: {o.id})"
問題点サマリー(7点)
1データクラス問題(Ch1-2) — CampaignDiscountDataが公開フィールドのみで、判断材料(is_active/end_at/target_rank)を持っているのに「自分が今適用可能か」を自分で答えられない
2判定ロジックの3重複製 — is_active → end_at → target_rank → typeの判定がMgr/CartPreviewService/NotificationServiceにほぼ同じ順序でコピペされている
3実装の食い違いバグ(直接の事故原因) — CartPreviewService.previewだけend_atチェックが抜けており、期限切れキャンペーンがカート表示にだけ残り続けた
4意味不明な命名 — Mgrが何を管理するクラスか不明。o, c, d, t, r, fという1文字変数が目的を語らない
5深いネスト — if d is not None: if d.is_active: if d.end_at > ...: if ...: の4段ネストで条件の組み合わせを目で追いにくい
6マジック文字列 — "PERCENT"/"FIXED"/"ALL"を素の文字列比較しており、typoしても実行時まで気づけない
7テスト困難性 — 判定にdatetime.now()を直接呼んでおり、期限切れ判定のテストを固定時刻で書けない

ヒント(段階的開示)

ヒント1 — 方向性
3つのクラスに同じような判定ロジック(is_active → end_at → target_rank → type)がほぼ同じ順番でコピペされています。これは「たまたま似ている3つの実装」ではなく、「本来1箇所にあるべき判断ロジックが、判断材料を持つデータクラス自身ではなく、データクラスを使う側に漏れ出している」状態です。この漏れ出し先が1箇所増えるたびに、実装の食い違い(今回のバグ)が起きるリスクが線形に増えます。
ヒント2 — アプローチ
CampaignDiscountDataを「フィールドだけ持つ入れ物」から「自分が今適用可能かどうかを自分で答えられるオブジェクト」に変えます。判定に必要な情報(is_active, start_at, end_at, target_rank)は元々このオブジェクトが全部持っているので、is_applicable_to(customer_rank, at)のようなメソッドをこのオブジェクト自身に生やせば、3つの呼び出し側は「聞くだけ」の存在になり、判定ロジックの実装は1箇所に集約されます。ネストの深さは、早期return(ガード節)で「条件を満たさない場合はすぐ返す」形に変えると自然に解消します。
ヒント3 — コードの骨格
class CampaignDiscount:  # ← Data サフィックスを外し、振る舞いを持つドメインオブジェクトへ
    def is_applicable_to(self, customer_rank, at) -> bool:
        if not self.is_active:
            return False
        if not (self.start_at <= at <= self.end_at):
            return False
        return self.target_rank == CustomerRank.ALL or self.target_rank == customer_rank

    def discount_amount(self, subtotal) -> Decimal:
        match self.discount_type:
            case DiscountType.PERCENT: ...
            case DiscountType.FIXED: ...
3つのサービスクラスは、この2つのメソッドを呼ぶだけの薄い実装になります。now(判定基準時刻)はdatetime.now()を内部で呼ばず引数で受け取ると、テストで時刻を固定できます。

問題点分析(7点)

#問題点分類改善方法
1CampaignDiscountDataが判断材料を持つのに振る舞いを持たないデータクラス問題 Ch1-2is_applicable_to/discount_amountをオブジェクト自身に実装
2判定ロジックが3クラスにほぼ同じ順序でコピペ重複CampaignDiscountの2メソッドに集約
3CartPreviewServiceだけend_atチェックが漏れ、表示と決済が食い違う実装の食い違いバグ重複を無くし判定を1箇所に固定
4Mgr / o,c,d,t,r,f という目的不明な命名意味不明な命名 Ch1-2OrderPricingService / subtotal,customer_rank等に改名
5if 4段ネストで条件を追いにくい深いネスト Ch1-2ガード節による早期return
6"PERCENT"/"FIXED"/"ALL"の素の文字列比較マジック文字列DiscountType/CustomerRankをStrEnum化
7datetime.now()を内部で直接呼びテスト不能テスト困難性now引数で判定基準時刻を注入

構造の比較(SVG)— 判定ロジックの漏れ出し vs ドメインオブジェクトへの集約

Bad: 判定ロジックが3箇所に漏れ出す CampaignDiscountData 公開フィールドのみ・振る舞い無し Mgr.calc is_active✓ end_at✓ target_rank✓ type✓ CartPreviewService is_active✓ end_at✗(漏れ) target_rank✓ type✓ NotificationService is_active✓ end_at✓ target_rank✓ type✓ 結果: 期限切れキャンペーンでも CartPreviewServiceだけ割引を表示し続ける → 注文確定(Mgr)では割引なしとなり 表示金額と決済金額が食い違う × 判定ロジックが3クラスに独立実装 × 1箇所の修正漏れが即バグに直結 × 4段ネストで条件の抜けに気づきにくい × 新しい参照箇所を作るたびコピペが増える Good: 判断材料を持つ本人に判断させる(Ch1-2) CampaignDiscount is_applicable_to(rank, at) -> bool discount_amount(subtotal) -> Decimal OrderPricingService 呼ぶだけの薄い実装 CartPreviewService 呼ぶだけの薄い実装 NotificationBuilder 呼ぶだけの薄い実装 結果: 3クラスとも同じ2メソッドを呼ぶだけ → end_atチェックの実装が1箇所に固定される → 「1クラスだけチェック漏れ」が 構造的に起こりえなくなる ✓ 判定ロジックの実装は1箇所のみ ✓ ガード節でネスト1段、条件の抜けが見つけやすい ✓ nowを引数化しテストで時刻固定可能 ✓ 新しい参照箇所を追加してもコピペ不要

模範解答

"""キャンペーン割引適用モジュール。

良いコード・悪いコードで学ぶ設計入門(改訂新版)
  第1-2章: 悪しき構造・設計の初歩 — 意味不明な命名、ネスト、データクラス問題

CampaignDiscount は自分が持つ判断材料(is_active/start_at/end_at/target_rank)を
使って「自分が今適用可能か」「いくら割引するか」を自分自身で答えられるドメイン
オブジェクトであり、旧CampaignDiscountDataのように呼び出し側へロジックを
漏らさない。これにより3箇所で発生していた重複実装と、その1つが更新から
取り残されて起きた本番バグ(CartPreviewServiceのend_atチェック漏れ)を
構造的に再発しえなくする。
"""
from __future__ import annotations

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


class DiscountType(StrEnum):
    """割引の計算方式。"""

    PERCENT = "PERCENT"
    FIXED = "FIXED"


class CustomerRank(StrEnum):
    """会員ランク。ALL は全ランクを対象とする特別値。"""

    ALL = "ALL"
    GOLD = "GOLD"
    PLATINUM = "PLATINUM"


PERCENT_DIVISOR = Decimal("100")  # パーセント表記(0-100)を小数の割引率に変換する除数


@dataclass(frozen=True, slots=True)
class CampaignDiscount:
    """キャンペーン割引そのものが「対象か」「いくら引くか」を自分で答えられるドメインオブジェクト。

    旧 CampaignDiscountData は公開フィールドだけを持つ「データクラス」で、適用可否判定
    (is_active × end_at × target_rank)と金額計算(PERCENT/FIXED分岐)のロジックが
    呼び出し側の Mgr / CartPreviewService / NotificationService の3箇所に重複していた。
    判断に必要な情報を全て持っているこのオブジェクト自身に振る舞いを持たせることで、
    重複と実装の食い違い(CartPreviewService だけ end_at チェックが漏れていたバグ)を
    構造的に起こりえなくする。
    """

    campaign_id: str
    discount_type: DiscountType
    value: Decimal
    start_at: datetime
    end_at: datetime
    target_rank: CustomerRank
    is_active: bool

    def is_applicable_to(self, customer_rank: CustomerRank, at: datetime) -> bool:
        """このキャンペーンが指定時刻・会員ランクに適用可能かを判定する。

        ガード節で早期returnし、ネストを1段に保つ。

        Args:
            customer_rank: 判定対象の会員ランク。
            at: 判定基準時刻。呼び出し側が注入することで、テストで固定時刻を使える。

        Returns:
            適用可能なら True。
        """
        if not self.is_active:
            return False
        if not (self.start_at <= at <= self.end_at):
            return False
        return self.target_rank == CustomerRank.ALL or self.target_rank == customer_rank

    def discount_amount(self, subtotal: Decimal) -> Decimal:
        """小計に対する割引額を計算する。

        Args:
            subtotal: 割引前の小計金額。

        Returns:
            割引額(円)。

        Raises:
            ValueError: 未知の discount_type が渡された場合(列挙値の網羅漏れを検知する)。
        """
        match self.discount_type:
            case DiscountType.PERCENT:
                return subtotal * (self.value / PERCENT_DIVISOR)
            case DiscountType.FIXED:
                return self.value
            case _:
                raise ValueError(f"未知の割引方式です: {self.discount_type}")


class OrderPricingService:
    """注文確定時の最終金額を計算する。"""

    def calculate_total(
        self,
        subtotal: Decimal,
        customer_rank: CustomerRank,
        discount: CampaignDiscount | None,
        now: datetime,
    ) -> Decimal:
        """割引適用後の注文金額を返す。"""
        if discount is None or not discount.is_applicable_to(customer_rank, now):
            return subtotal
        return subtotal - discount.discount_amount(subtotal)


class CartPreviewService:
    """カート画面のプレビュー金額を計算する。"""

    def preview_total(
        self,
        subtotal: Decimal,
        customer_rank: CustomerRank,
        discount: CampaignDiscount | None,
        now: datetime,
    ) -> Decimal:
        """カート表示用の金額を返す。OrderPricingService と同じ判定ロジックを再利用する。"""
        if discount is None or not discount.is_applicable_to(customer_rank, now):
            return subtotal
        return subtotal - discount.discount_amount(subtotal)


class DiscountNotificationBuilder:
    """Slackへの割引適用通知メッセージを組み立てる。"""

    def build_message(
        self,
        order_id: str,
        subtotal: Decimal,
        customer_rank: CustomerRank,
        discount: CampaignDiscount | None,
        now: datetime,
    ) -> str:
        """割引適用結果を通知文言として返す。"""
        applied_amount = Decimal("0")
        if discount is not None and discount.is_applicable_to(customer_rank, now):
            applied_amount = discount.discount_amount(subtotal)
        return f"割引 {applied_amount}円 を適用しました(注文ID: {order_id})"
now = datetime(2026, 8, 31, 10, 0)

expired_campaign = CampaignDiscount(
    campaign_id="SUMMER10",
    discount_type=DiscountType.PERCENT,
    value=Decimal("10"),
    start_at=datetime(2026, 8, 1, 0, 0),
    end_at=datetime(2026, 8, 30, 23, 59),  # 前日に終了済み
    target_rank=CustomerRank.ALL,
    is_active=True,
)

pricing = OrderPricingService()
preview = CartPreviewService()
notifier = DiscountNotificationBuilder()

print(pricing.calculate_total(Decimal("10000"), CustomerRank.GOLD, expired_campaign, now))
# => 10000  (期限切れなので割引なし)

print(preview.preview_total(Decimal("10000"), CustomerRank.GOLD, expired_campaign, now))
# => 10000  (旧実装ではここだけ 9000 になっていた = プレビューと決済金額が食い違うバグ)

print(notifier.build_message("ORD-001", Decimal("10000"), CustomerRank.GOLD, expired_campaign, now))
# => "割引 0円 を適用しました(注文ID: ORD-001)"

# 3箇所すべてが CampaignDiscount.is_applicable_to を呼ぶだけになったため、
# 判定結果は常に一致する。判定ロジックへ将来変更が入っても、修正箇所は
# CampaignDiscount の1メソッドのみで済み、修正漏れによる食い違いは
# 構造的に起こらない。
ポイント適用した設計原則書籍対応章
CampaignDiscountData(振る舞い無し)をCampaignDiscount(振る舞い有り)へデータクラス問題の解消Ch1-2
Mgr/o,c,d,t,r,fを目的が読み取れる名前へ改名目的駆動命名Ch1-2 / Ch11関連
4段ネストをガード節でフラット化早期returnによるネスト解消Ch1-2 / Ch8関連
"PERCENT"/"FIXED"/"ALL"をStrEnum化マジック文字列の排除Ch10関連
Before(Bad)After(Good)
CampaignDiscountDataは公開フィールドのみで判断ロジックを持たないCampaignDiscountがis_applicable_to/discount_amountを自ら実装
判定ロジックがMgr/CartPreviewService/NotificationServiceに3重複3クラスはCampaignDiscountの2メソッドを呼ぶだけの薄い実装
CartPreviewServiceだけend_atチェックが実装から漏れる判定の実装が1箇所のため漏れが構造的に起こらない
Mgr / o,c,d,t,r,f という目的不明な命名OrderPricingService / subtotal,customer_rank等の目的駆動命名
datetime.now()を内部で直接呼びテスト不能nowを引数化しテストで固定時刻を注入可能

ポイント解説

1データクラス問題の解消(Ch1-2) — CampaignDiscountDataは公開フィールドを持つだけで「自分が何であるか(=いつ・誰に適用できるか)」を一切語れなかった。判断材料を持つオブジェクトに判断ロジックを持たせる(=データと振る舞いを同じ場所に置く)のがオブジェクト指向の基本であり、これが崩れると「同じ判定をあちこちに書き写す」羽目になる。
2重複ロジックの一元化と食い違いバグの根絶 — 3クラスに散っていたis_active → end_at → target_rank → typeの判定をCampaignDiscount.is_applicable_to / discount_amountの2メソッドに集約した。今回のバグ(CartPreviewServiceだけend_atチェック漏れ)は「複製されたロジックの1つが更新から取り残される」典型パターンであり、複製そのものをなくすことで再発を構造的に防げる。
3意味不明な命名の是正(Ch1-2) — Mgr(何を管理するのか不明)→ OrderPricingService(注文の価格計算を行う)、o/c/d/t/r/fの1文字変数 → subtotal/customer_rank/discount/applied_amountなど目的が読み取れる名前に変更した。
4ネストの解消(Ch1-2) — if d is not None: if d.is_active: if d.end_at > ...: if ...:という4段ネストを、is_applicable_to内のガード節(早期return)でフラット化した。呼び出し側もif discount is None or not discount.is_applicable_to(...): return subtotalの1行ガードで済むようになった。
5StrEnum とテスト容易性 — type: strという素朴な文字列比較をDiscountType / CustomerRankのStrEnumに置き換え、match文で網羅性チェック(case _: raise ValueError)を入れた。またdatetime.now()を内部で直接呼ばずnowを引数として受け取る形にしたことで、期限切れ判定のテストが時刻に依存せず書けるようになった。

実務への応用

MOps 販促システムチームでは、キャンペーン・クーポン・会員ランク特典など「条件付き適用ロジック」を持つマスタデータが多数存在する。今回のように「注文確定処理」「カートプレビュー」「メール文言生成」「Slack通知」など複数の画面・バッチが同じマスタを参照する場面では、判定ロジックをマスタ側のドメインオブジェクトに寄せておくことで、新しい参照箇所(例: 今後追加されるLINE通知)を作る際にロジックのコピペが発生せず、実装漏れによる「表示と実際の挙動が食い違う」系のインシデントを構造的に防げる。

レビューでの見つけ方: PRの中にif ... is_activeやend_at > ...のような判定条件が2箇所以上に登場したら、それはデータクラス問題のサインである可能性が高い。「その判定はどのオブジェクトの状態を見ているか」を問い、そのオブジェクト自身にメソッドとして持たせられないかを検討する。

今日のまとめ

「データの入れ物」に判断ロジックを持たせず外部に漏らすと、参照箇所が増えるたびにコピペ実装が増え、そのうちの1箇所が更新から取り残されて食い違いバグを生む。判断材料を持つオブジェクト自身に判断させることが、重複とバグの両方を構造的に防ぐ最短経路である。

次のステップ

  • 発展問題: CampaignDiscountを複数同時適用(クーポン+会員ランク特典の重複適用)に対応させる場合、discount_amountの単純な減算では「割引の重ね掛けで合計が0円未満になる」といった不整合が起きうる。第8章のポリシーパターンで「複数の割引をどの順序・上限でどう合成するか」を切り出す設計を検討せよ
  • 参考: 「良いコード・悪いコードで学ぶ設計入門」第1-2章(悪しき構造・設計の初歩)、第3章(カプセル化との関連 — 完全コンストラクタでの不正値排除)

自己評価(あとで記入)