概要
単一責任原則(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」だけでなく「キャンペーン種別」も区別してキー化し、インシデントを再発させないこと
- 新しいキャンペーン種別を追加する際に、通知・監査ログを担うクラスへの変更が発生しない設計にすること
悪いコード (Before)
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"]が再利用されてバグる
_cacheが顧客IDのみでキー化され、継承階層をまたいでキャンペーン種別間の結果が誤って共有される(インシデントの直接原因)fetch_past_orders呼び出しが3つのサブクラスにコピペされているSlackWebhookClient/bigquery.Clientへの直接依存でユニットテストがモックできないcheck()相当の判定処理に通知・DB書き込みという副作用が混在し、「判定だけしたい」呼び出し方ができないヒント(段階的開示)
ヒント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クラスに同居 | 神クラス Ch6 | EligibilityRule/NotificationPort/AuditLogPortに分離 |
| 2 | 実装再利用のためだけにサブクラス化 | 継承誤用 Ch7 | Rule/Portをコンストラクタ・引数で委譲 |
| 3 | _cacheが顧客IDのみでキー化されキャンペーン種別を跨いで誤共有 | 状態共有バグ | (customer_id, rule.name)でキー化 |
| 4 | 過去注文取得ロジックが3サブクラスに重複 | 重複 | Ruleごとに必要なcontext値のみ受け取る |
| 5 | Slack/BigQueryへの直接依存でテスト不能 | テスト困難性 | Port(Protocol)経由でDI、Fake実装に差し替え |
| 6 | 判定処理に通知・DB書き込みの副作用が混在 | コマンドクエリ分離違反 | is_eligible()を純粋な判定のみに限定 |
| 7 | 新規キャンペーン追加のたびに継承階層の見直しが必要 | OCP違反 | Ruleクラスを1つ追加するだけで拡張可能に |
構造の比較(SVG)— 継承階層 vs 委譲による組み立て
模範解答
"""キャンペーン対象判定モジュール。
良いコード・悪いコードで学ぶ設計入門(改訂新版)
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をコンストラクタで注入 |
ポイント解説
PromotionEligibilityServiceのサブクラスとして表現していたが、これは「優良顧客ランクキャンペーンは判定サービスの一種である」というis-a関係を表しているわけではなく、単にSlack通知・BigQuery書き込みのコードを再利用したいだけだった。継承階層を共有した結果、クラス変数_cacheがサブクラス(=キャンペーン種別)をまたいで共有されるという、直接インシデントの原因になったバグを生んだ。判定ロジックをRuleオブジェクトとして切り出し、Checkerに委譲(コンストラクタ・引数として渡す)する設計に変えることで、この共有状態の問題自体が構造的に消える。PromotionEligibilityServiceは「判定ロジックの変更」「通知先の変更」「監査ログの形式変更」という3つの独立した変更理由を1クラスに抱えていた。EligibilityRule/NotificationPort/AuditLogPortに責務を分離したことで、それぞれの変更が他に影響を与えなくなり、新しいキャンペーン種別の追加もEligibilityRuleを実装するだけで完結する(PromotionEligibilityCheckerは無変更)。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と関連が深い)