A コーディング — 単一責任原則・継承より委譲・スマートUI/神クラス対処(キャンペーン対象判定 PromotionEligibilityService Bad→Good Ch6-7)

2026-08-24 (Day 140) 月曜 コーディング ★★★★☆ Python 3.12 / Protocol / dataclass(frozen) 良いコード設計入門 Ch6-7(関心の分離)

概要

🧩

単一責任原則(Ch6)

クラスが変更される理由は1つに絞る。「判定ロジックの変更」「通知先の変更」「ログ形式の変更」という別々の理由で同じクラスが変更されるなら、それは責務が混在している兆候。

🔗

継承より委譲(Ch7)

「is-a」関係がないのに実装再利用のためだけにサブクラス化すると、無関係な状態(クラス変数など)まで継承階層全体で共有されてしまう。振る舞いは継承ではなく、外から渡す(委譲する)。

👑

スマートUI・神クラス対処(Ch7)

判定・通知・ログ書き込みのような異なる関心事が1クラスに同居すると、変更のたびに無関係な部分まで壊れるリスクを抱える「神クラス」になる。

💥

実際に起きた事故

クラス変数_cacheが顧客IDのみでキー化されていたため、「初回購入キャンペーン」の判定結果が同一顧客の「優良顧客ランクキャンペーン」判定にそのまま誤って再利用され、本来対象の顧客が対象外と判定された。

問題

EC企業 MOps 販促システムチームでは、注文が各種キャンペーン(初回購入限定・優良顧客ランク限定・クーポン併用可)の対象かどうかを判定する PromotionEligibilityService が本番稼働している。判定に加えて、Slackへの結果通知・BigQueryへの監査ログ書き込みまでを同じクラス階層が担っており、次のインシデントが起きた。

先週起きたインシデント: ある顧客が「初回購入キャンペーン」の対象外と判定された直後、同じリクエスト内で「優良顧客ランクキャンペーン」の判定も呼び出したところ、本来対象になるはずなのにFalseが返された。原因調査の結果、PromotionEligibilityServiceの基底クラスが持つキャッシュ用のクラス変数_cacheが顧客IDのみをキーにしており、キャンペーン種別(サブクラスの違い)を区別していなかったことが判明した。「初回購入キャンペーン」の判定結果がキャッシュに書き込まれた直後、同一顧客に対する「優良顧客ランクキャンペーン」の判定がそのキャッシュを誤って再利用していた。

「良いコード・悪いコードで学ぶ設計入門」第6-7章(関心の分離:単一責任原則、継承より委譲、スマートUI・神クラス対処)の考え方を使って、PromotionEligibilityServiceを再設計してください。

制約・前提条件

  • Python 3.12+
  • 判定対象のキャンペーンは「初回購入限定」「優良顧客ランク限定」「クーポン併用可」の3種類。将来的にキャンペーン種別が追加される前提で設計すること
  • 判定ロジックは「顧客・注文の情報」だけから純粋に結果を返せるようにし、Slack通知・BigQuery監査ログ書き込みという副作用を判定ロジックから分離すること
  • Slack通知・BigQuery監査ログ書き込みは外部サービスへの実アクセスを伴うため、ユニットテストではモック(フェイク実装)に差し替えられる設計にすること
  • 判定結果のキャッシュは「顧客ID」だけでなく「キャンペーン種別」も区別してキー化し、インシデントを再発させないこと
  • 新しいキャンペーン種別を追加する際に、通知・監査ログを担うクラスへの変更が発生しない設計にすること
期待する回答形式: 問題点の列挙(番号付き)+ 改善後コード(docstring・インラインコメント・名前付き定数含む)+ 実行例(input→output)+ 適用した設計パターン名と対応章

悪いコード (Before)

このコードには 7つの問題 が隠れています。見つけてみてください。
bad_promotion.py — 判定/通知/ログが同居した継承階層
class PromotionEligibilityService:
    _cache = {}  # 顧客IDのみでキー化、キャンペーン種別を無視

    def __init__(self, customer_id, order_total):
        self.customer_id = customer_id
        self.order_total = order_total
        self.slack_client = SlackWebhookClient(url=SLACK_WEBHOOK_URL)
        self.bq_client = bigquery.Client()

    def check(self):
        raise NotImplementedError

    def send_slack_notification(self, message):
        self.slack_client.post(message)

    def write_audit_log(self, result):
        table = self.bq_client.dataset("mops").table("promo_audit_log")
        self.bq_client.insert_rows(table, [{
            "customer_id": self.customer_id,
            "result": result,
        }])

    def run(self):
        if self.customer_id in self._cache:
            return self._cache[self.customer_id]
        result = self.check()
        self._cache[self.customer_id] = result
        self.write_audit_log(result)
        self.send_slack_notification(f"{self.customer_id}: {result}")
        return result


class FirstPurchasePromotion(PromotionEligibilityService):
    def check(self):
        orders = fetch_past_orders(self.customer_id)  # 重複実装①
        return len(orders) == 0 and self.order_total >= 3000


class LoyaltyTierPromotion(PromotionEligibilityService):
    def check(self):
        orders = fetch_past_orders(self.customer_id)  # 重複実装②
        tier = fetch_member_tier(self.customer_id)
        return tier in ("gold", "platinum") and len(orders) >= 5


class CouponStackPromotion(PromotionEligibilityService):
    def check(self):
        orders = fetch_past_orders(self.customer_id)  # 重複実装③(未使用)
        return self.order_total >= 5000

# --- 呼び出し側 ---
promo = FirstPurchasePromotion(customer_id="C001", order_total=4000)
promo.run()  # => True

promo2 = LoyaltyTierPromotion(customer_id="C001", order_total=8000)
promo2.run()  # => True のはずが、_cache["C001"]が再利用されてバグる
問題点サマリー(7点)
1神クラス(Ch6) — 「判定ロジック」「通知」「監査ログ書き込み」という変更理由の異なる責務が1クラスに同居している
2継承の誤用(Ch7) — is-a関係がないのに、通知・ログ処理を再利用したいだけでキャンペーン種別をサブクラス化している
3クラス変数の誤共有 — _cacheが顧客IDのみでキー化され、継承階層をまたいでキャンペーン種別間の結果が誤って共有される(インシデントの直接原因)
4ロジックの重複 — fetch_past_orders呼び出しが3つのサブクラスにコピペされている
5テスト困難性 — SlackWebhookClient/bigquery.Clientへの直接依存でユニットテストがモックできない
6コマンド・クエリ分離違反 — check()相当の判定処理に通知・DB書き込みという副作用が混在し、「判定だけしたい」呼び出し方ができない
7拡張性の欠如(OCP違反) — 新しいキャンペーン種別を追加するたびに継承階層全体への影響を確認する必要がある

ヒント(段階的開示)

ヒント1 — 方向性
PromotionEligibilityServiceは「入力検証」「キャンペーン対象判定ロジック」「Slack通知」「BigQuery監査ログ書き込み」という、変更理由の異なる4つの責務を1つのクラスに抱え込んでいます(単一責任原則違反)。さらに3つのキャンペーン種別をPromotionEligibilityServiceのサブクラスとして表現していますが、これらは「is-a」関係(優良顧客ランクキャンペーンは判定サービスの一種である)ではなく、単に通知・ログ処理を再利用したいだけの都合で継承を使っています。この「実装の再利用のためだけの継承」が、インシデントの直接の原因である「サブクラスをまたいでクラス変数_cacheが共有される」というバグを生んでいます。「継承ではなく、判定ロジックを外から渡す(委譲する)としたら、この階層はどう組み替えられるか」を考えてみてください。
ヒント2 — アプローチ
  • EligibilityRule(判定ロジックの抽象化): 「初回購入限定」「優良顧客ランク限定」「クーポン併用可」それぞれの判定ロジックを、is_eligible(context) -> boolという1メソッドだけを持つ独立したクラス(Protocolで抽象化)として切り出す。副作用は一切持たせない
  • NotificationPort / AuditLogPort(副作用の抽象化): Slack通知・BigQuery監査ログ書き込みも、それぞれ専用のインターフェース(Protocol)として切り出し、本番用の実装(Slack/BigQuery)とテスト用のフェイク実装を差し替え可能にする
  • PromotionEligibilityChecker(組み合わせ役): 判定ロジック・通知・監査ログのいずれも自分では持たず、コンストラクタで受け取ったNotificationPort/AuditLogPortと、run()の引数で受け取ったEligibilityRuleを「組み合わせて呼び出すだけ」の薄いクラスにする
  • キャッシュのキーは(customer_id, rule.name)のタプルにし、キャンペーン種別をまたいだ誤共有を構造的に防ぐ
ヒント3 — コードの骨格
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol

@dataclass(frozen=True, slots=True)
class OrderContext:
    customer_id: str
    order_total: int
    past_order_count: int
    member_tier: str | None

class EligibilityRule(Protocol):
    name: str
    def is_eligible(self, context: OrderContext) -> bool: ...

@dataclass(frozen=True, slots=True)
class FirstPurchaseRule:
    name: str = "first_purchase"
    min_order_total: int = 3000
    def is_eligible(self, context: OrderContext) -> bool:
        return ...

class NotificationPort(Protocol):
    def notify(self, message: str) -> None: ...

class AuditLogPort(Protocol):
    def record(self, customer_id: str, rule_name: str, result: bool) -> None: ...

class PromotionEligibilityChecker:
    def __init__(self, notifier: NotificationPort, audit_log: AuditLogPort) -> None:
        self._notifier = notifier
        self._audit_log = audit_log
        self._cache: dict[tuple[str, str], bool] = {}

    def run(self, rule: EligibilityRule, context: OrderContext) -> bool:
        cache_key = (context.customer_id, rule.name)
        ...

問題点分析(7点)

#問題点分類改善方法
1判定/通知/監査ログの責務が1クラスに同居神クラス Ch6EligibilityRule/NotificationPort/AuditLogPortに分離
2実装再利用のためだけにサブクラス化継承誤用 Ch7Rule/Portをコンストラクタ・引数で委譲
3_cacheが顧客IDのみでキー化されキャンペーン種別を跨いで誤共有状態共有バグ(customer_id, rule.name)でキー化
4過去注文取得ロジックが3サブクラスに重複重複Ruleごとに必要なcontext値のみ受け取る
5Slack/BigQueryへの直接依存でテスト不能テスト困難性Port(Protocol)経由でDI、Fake実装に差し替え
6判定処理に通知・DB書き込みの副作用が混在コマンドクエリ分離違反is_eligible()を純粋な判定のみに限定
7新規キャンペーン追加のたびに継承階層の見直しが必要OCP違反Ruleクラスを1つ追加するだけで拡張可能に

構造の比較(SVG)— 継承階層 vs 委譲による組み立て

Bad: 継承で通知・ログを共有 → 状態も共有される PromotionEligibilityService(基底クラス) _cache = {} ← 顧客IDのみでキー化(クラス変数) check() / send_slack_notification() / write_audit_log() 4つの責務が1クラスに同居 FirstPurchase Promotion(基底) _cache共有 LoyaltyTier Promotion(基底) _cache共有 CouponStack Promotion(基底) _cache共有 結果: FirstPurchase判定 → _cache["C001"]=True書き込み → 直後のLoyaltyTier判定が同じキーを誤って再利用 → 別キャンペーンなのに判定をスキップして誤った結果 is-a関係がないのに継承 → 無関係な状態まで共有される × 通知/ログ変更がサブクラス全体に影響 × SlackやBigQueryをモックできずテスト不能 × 新種別追加のたびに継承階層を再設計 × fetch_past_orders()が3箇所に重複 Good: 委譲で組み立てる(Ch7) → Checkerは薄いまま PromotionEligibilityChecker(責務: 組み合わせのみ) _cache: dict[(customer_id, rule.name), bool] FirstPurchaseRule is_eligible()のみ LoyaltyTierRule is_eligible()のみ CouponStackRule is_eligible()のみ NotificationPort Slack実装 / Fake実装を注入 AuditLogPort BigQuery実装 / Fake実装を注入 結果: cache_key=(customer_id, rule.name)で判定 → FirstPurchaseRuleとLoyaltyTierRuleは別キーとして扱われる → キャンペーン種別を跨いだ誤共有が構造的に発生しない Ruleに継承関係なし → 各Ruleは互いに無関係、独立にテスト可能 ✓ 新種別追加はRuleクラス1つの実装で完結 ✓ Fake Port注入で外部アクセス無しにテスト可能 ✓ 判定/通知/ログの変更が互いに影響しない ✓ Checkerは組み合わせ役のみで変更されない

模範解答

"""キャンペーン対象判定モジュール。

良いコード・悪いコードで学ぶ設計入門(改訂新版)
  Ch6-7: 関心の分離 — 単一責任原則、継承より委譲、スマートUI・神クラス対処

判定ロジック(EligibilityRule)・通知(NotificationPort)・監査ログ(AuditLogPort)の
3つの責務をそれぞれ別のオブジェクトに分離し、PromotionEligibilityCheckerは
それらを「組み合わせる」役割だけを持つ。継承ではなく委譲(コンストラクタ注入)で
振る舞いを組み立てることで、キャンペーン種別が増えてもCheckerは変更不要になる。
"""
from __future__ import annotations

from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Protocol

# キャッシュ未登録を判定するための番兵オブジェクト(Noneが正当な値と衝突しないようにする)
_CACHE_UNSET = object()


@dataclass(frozen=True, slots=True)
class OrderContext:
    """キャンペーン対象判定に必要な入力をまとめた値オブジェクト。

    Attributes:
        customer_id: 判定対象の顧客ID。
        order_total: 今回の注文金額(円)。
        past_order_count: 過去の注文件数。
        member_tier: 会員ランク("gold" / "platinum" 等)。未加入はNone。
    """
    customer_id: str
    order_total: int
    past_order_count: int
    member_tier: str | None = None


class EligibilityRule(Protocol):
    """キャンペーン対象判定ルールのインターフェース。

    判定のみを行い、通知やログ書き込みといった副作用を一切持たない
    (純粋関数的なオブジェクトとして扱う)。
    """
    name: str

    def is_eligible(self, context: OrderContext) -> bool:
        """contextがこのキャンペーンの対象条件を満たすかを返す。"""
        ...


@dataclass(frozen=True, slots=True)
class FirstPurchaseRule:
    """初回購入限定キャンペーンの対象判定ルール。"""
    name: str = "first_purchase"
    min_order_total: int = 3000  # 対象となる最低注文金額(円)

    def is_eligible(self, context: OrderContext) -> bool:
        return (
            context.past_order_count == 0
            and context.order_total >= self.min_order_total
        )


@dataclass(frozen=True, slots=True)
class LoyaltyTierRule:
    """優良顧客ランク限定キャンペーンの対象判定ルール。"""
    name: str = "loyalty_tier"
    eligible_tiers: tuple[str, ...] = field(default=("gold", "platinum"))
    min_past_orders: int = 5  # 対象となる最低の過去注文件数

    def is_eligible(self, context: OrderContext) -> bool:
        return (
            context.member_tier in self.eligible_tiers
            and context.past_order_count >= self.min_past_orders
        )


@dataclass(frozen=True, slots=True)
class CouponStackRule:
    """クーポン併用可キャンペーンの対象判定ルール。"""
    name: str = "coupon_stack"
    min_order_total: int = 5000  # 対象となる最低注文金額(円)

    def is_eligible(self, context: OrderContext) -> bool:
        return context.order_total >= self.min_order_total


class NotificationPort(Protocol):
    """判定結果の通知先を抽象化するインターフェース。"""
    def notify(self, message: str) -> None: ...


class AuditLogPort(Protocol):
    """判定結果の監査ログ書き込み先を抽象化するインターフェース。"""
    def record(self, customer_id: str, rule_name: str, result: bool) -> None: ...


class SlackNotificationAdapter:
    """NotificationPortのSlack実装(本番用)。"""

    def __init__(self, client: SlackWebhookClient) -> None:
        self._client = client

    def notify(self, message: str) -> None:
        self._client.post(message)


class BigQueryAuditLogAdapter:
    """AuditLogPortのBigQuery実装(本番用)。"""

    def __init__(self, bq_client: bigquery.Client, table_ref: str) -> None:
        self._bq_client = bq_client
        self._table_ref = table_ref

    def record(self, customer_id: str, rule_name: str, result: bool) -> None:
        self._bq_client.insert_rows(
            self._table_ref,
            [{
                "customer_id": customer_id,
                "rule_name": rule_name,
                "result": result,
                "checked_at": datetime.now(timezone.utc).isoformat(),
            }],
        )


class PromotionEligibilityChecker:
    """キャンペーン対象判定を組み合わせて実行する、単一責任のオーケストレータ。

    判定ロジック(EligibilityRule)・通知(NotificationPort)・監査ログ(AuditLogPort)
    のいずれも自身では実装せず、コンストラクタで注入されたコラボレータへ委譲する。
    このクラスの変更理由は「判定結果の組み合わせ方(キャッシュ・記録の手順)」のみに
    限定され、新しいキャンペーン種別の追加や通知先の変更ではこのクラスは変更されない。

    Attributes:
        _notifier: 判定結果を通知するためのポート。
        _audit_log: 判定結果を記録するためのポート。
        _cache: (顧客ID, ルール名)をキーにした判定結果キャッシュ。
            キャンペーン種別をまたいだ誤共有を防ぐため、ルール名を必ずキーに含める。
    """

    def __init__(self, notifier: NotificationPort, audit_log: AuditLogPort) -> None:
        self._notifier = notifier
        self._audit_log = audit_log
        self._cache: dict[tuple[str, str], bool] = {}

    def run(self, rule: EligibilityRule, context: OrderContext) -> bool:
        """指定ルールで対象判定を行い、通知・監査ログを1回だけ記録する。

        Args:
            rule: 適用するキャンペーン判定ルール。
            context: 判定に必要な顧客・注文情報。

        Returns:
            キャンペーン対象であればTrue。
        """
        cache_key = (context.customer_id, rule.name)
        cached = self._cache.get(cache_key, _CACHE_UNSET)
        if cached is not _CACHE_UNSET:
            return cached  # type: ignore[return-value]

        result = rule.is_eligible(context)
        self._cache[cache_key] = result
        self._audit_log.record(context.customer_id, rule.name, result)
        self._notifier.notify(f"{context.customer_id} / {rule.name}: {result}")
        return result
# --- 本番相当の組み立て ---
checker = PromotionEligibilityChecker(
    notifier=SlackNotificationAdapter(slack_client),
    audit_log=BigQueryAuditLogAdapter(bq_client, "mops.promo_audit_log"),
)

ctx1 = OrderContext(customer_id="C001", order_total=4000, past_order_count=0)
print(checker.run(FirstPurchaseRule(), ctx1))
# => True (Slack通知・BigQuery監査ログ書き込みがそれぞれ1回だけ実行される)

# 同一顧客に対して別キャンペーン種別を判定
ctx2 = OrderContext(customer_id="C001", order_total=8000, past_order_count=6, member_tier="gold")
print(checker.run(LoyaltyTierRule(), ctx2))
# => True
# 旧実装ではキャッシュキーが顧客IDのみだったため、直前のFirstPurchaseRuleの
# 判定結果(True)がそのまま誤って返っていた(インシデントの再現)。
# 新実装は(customer_id, rule.name)でキー化しているため、別ルールとして正しく再判定される。

# --- テスト時: 実際のSlack/BigQueryにアクセスせずに検証 ---
class FakeNotifier:
    def __init__(self) -> None:
        self.messages: list[str] = []
    def notify(self, message: str) -> None:
        self.messages.append(message)

class FakeAuditLog:
    def __init__(self) -> None:
        self.records: list[tuple[str, str, bool]] = []
    def record(self, customer_id: str, rule_name: str, result: bool) -> None:
        self.records.append((customer_id, rule_name, result))

test_checker = PromotionEligibilityChecker(notifier=FakeNotifier(), audit_log=FakeAuditLog())
ctx3 = OrderContext(customer_id="C999", order_total=6000, past_order_count=2)
assert test_checker.run(CouponStackRule(), ctx3) is True
ポイント適用した設計原則書籍対応章
判定ロジックをEligibilityRuleとして切り出し、通知・ログと分離単一責任原則Ch6
キャンペーン種別をサブクラスではなくRuleオブジェクトの委譲で表現継承より委譲Ch7
判定・通知・ログ書き込みが1クラスに同居していた状態を解消スマートUI・神クラス対処Ch7
Slack/BigQueryをPort(Protocol)で抽象化しテスト容易性を確保依存性逆転(DIP)Ch7関連
Before(Bad)After(Good)
キャンペーン種別ごとにPromotionEligibilityServiceを継承EligibilityRuleを実装したクラスをCheckerに委譲
_cacheがクラス変数で顧客IDのみキー化インスタンス変数で(customer_id, rule.name)キー化
check()内に判定・通知・DB書き込みが混在is_eligible()は判定のみ、副作用はPort経由でChecker側が実行
Slack/BigQueryクライアントをクラス内で直接生成NotificationPort/AuditLogPortをコンストラクタで注入

ポイント解説

1「実装の再利用のための継承」は誤用である(Ch7) — 旧実装は3つのキャンペーン種別をPromotionEligibilityServiceのサブクラスとして表現していたが、これは「優良顧客ランクキャンペーンは判定サービスの一種である」というis-a関係を表しているわけではなく、単にSlack通知・BigQuery書き込みのコードを再利用したいだけだった。継承階層を共有した結果、クラス変数_cacheがサブクラス(=キャンペーン種別)をまたいで共有されるという、直接インシデントの原因になったバグを生んだ。判定ロジックをRuleオブジェクトとして切り出し、Checkerに委譲(コンストラクタ・引数として渡す)する設計に変えることで、この共有状態の問題自体が構造的に消える。
2単一責任原則は「変更理由を1つに絞る」ことで神クラス化を防ぐ(Ch6) — 旧PromotionEligibilityServiceは「判定ロジックの変更」「通知先の変更」「監査ログの形式変更」という3つの独立した変更理由を1クラスに抱えていた。EligibilityRule/NotificationPort/AuditLogPortに責務を分離したことで、それぞれの変更が他に影響を与えなくなり、新しいキャンペーン種別の追加もEligibilityRuleを実装するだけで完結する(PromotionEligibilityCheckerは無変更)。
3判定ロジックから副作用を追い出すと、キャッシュのバグが構造的に発見しやすくなる(Ch7) — EligibilityRule.is_eligible()を「入力から結果を返すだけ」の純粋な関数的メソッドにしたことで、PromotionEligibilityChecker.run()側だけを見れば「キャッシュキーに何を使うべきか」が一目で判断できるようになった。判定・通知・ログが1メソッドに混在していた旧実装では、キャッシュのキー設計ミスがロジックの中に埋もれて発見しにくかった。

実務への応用

MOps 販促システムチームでは、キャンペーン種別が今後も増える見込みであり、都度サブクラスを追加する設計のままでは「新しいキャンペーンを追加するたびに、既存の通知・監査ログ処理まで意図せず影響を受けないか」をレビューでいちいち確認する必要が生じる。EligibilityRuleを委譲で組み立てる設計にしておけば、新しいキャンペーン種別の追加は「Ruleクラスを1つ実装してChecker.run()に渡す」だけで完結し、既存コードへの変更差分をレビュー時に確認しやすくなる。

インフラ変更への耐性: NotificationPort/AuditLogPortの抽象化は、Slack通知をやめてPub/Sub経由の社内ダッシュボード通知に切り替える、監査ログの保存先をBigQueryからCloud Loggingに変える、といった将来のインフラ変更にも判定ロジックを一切触らずに対応できる。

今日のまとめ

_cacheが顧客IDだけでキー化されていたバグは、キャッシュ設計だけの問題ではなく「実装再利用のためだけに継承を使い、判定・通知・ログという異なる責務を1クラスに同居させた」という設計上の根本原因の症状の1つであり、責務をRule/Port単位で分離し継承を委譲に置き換えることで再発そのものを構造的に防げる。

次のステップ

  • 発展問題: EligibilityRuleを複数組み合わせて「初回購入 かつ クーポン併用可」のようにAND/OR条件でキャンペーンを合成できるようにするCompositeRuleを、継承ではなく委譲(Composite/Decoratorパターン)で設計する
  • 参考: 「良いコード・悪いコードで学ぶ設計入門」第6章(関心の分離)、第7章(継承より委譲・スマートUI・神クラス)、第8章(条件分岐・ストラテジパターン、EligibilityRuleと関連が深い)

自己評価(あとで記入)