概要
技術駆動命名の回避(Ch11)
CouponMgr(Manager接尾辞)を廃し、実体である Coupon という名詞そのものに振る舞いを持たせる。クラス名が「何をする箱か」ではなく「何であるか」を表すようにする。
省略しない・目的駆動の命名(Ch11)
chk / proc / exec / d / u / flg という1〜3文字の省略名・汎用動詞を、is_redeemable_by / calculate_discounted_amount / redeem / eligibility / member / is_redeemed というドメイン語彙に置き換える。
組み込み名との衝突回避(Ch11)
exec は Python の組み込み関数と同名。IDE補完の誤爆・可読性低下を招くため、ドメイン語彙の redeem(クーポンを引き換える)に置き換える。
StrEnum × 値オブジェクト(Ch8/Ch3)
"fix" / "pct" というマジック文字列を DiscountType(StrEnum) に、d["min_lv"] という dict キーを CouponEligibility.minimum_member_level という属性に置き換え、タイポを型チェック・実行時エラーの両方で検出可能にする。
問題
ECサイト MOps チームでは、キャンペーンクーポンの発行・適用ロジックを Python で実装している。以下の「悪いコード」は CouponMgr という技術駆動命名のクラスに、d, t, u, amt, res, flg といった1〜3文字の省略変数名、chk, proc, exec という汎用的すぎるメソッド名が詰め込まれており、名前だけを読んでも「何の責務を持つクラスか」「各メソッドが何をするのか」が全く分からない状態になっている。
問題点を全て洗い出し、「良いコード・悪いコードで学ぶ設計入門」第11章(名前設計)の 目的駆動命名・技術駆動命名の回避・省略しない命名 を適用して Bad→Good にリファクタリングしてください(併せて第3章の完全コンストラクタ・値オブジェクト、第8章の StrEnum によるマジック文字列排除も適用すること)。
制約・前提条件
- Python 3.12+、値オブジェクトには
dataclass(frozen=True, slots=True)を使用すること - クラス名・メソッド名・変数名は、実装を読まなくても「何をするものか」がドメイン語彙から推測できる名前にすること(
Manager/Data/Infoのような技術駆動の汎用接尾辞は禁止) - 真偽値を返すメソッド・属性には
is_/has_等の接頭辞を付けること - Python の組み込み関数・キーワードと衝突する名前(
exec等)は使用しないこと - 割引方式(定額/定率)は文字列のマジック値ではなく
StrEnumで表現すること - Google スタイル docstring・インラインコメント・名前付き定数を含めること
悪いコード (Before)
class CouponMgr:
def __init__(self, d, t):
# 問題1: クラス名が Manager接尾辞(技術駆動命名)
self.d = d # 問題6: dict キーが min_lv, val という省略・意味不明な名前
self.t = t # 問題7: "fix" or "pct" というマジック文字列
self.flg = False # 問題5: 真偽値なのに is_/has_ 接頭辞がない
# 問題3: chk という汎用動詞のみのメソッド名(何をチェックするか不明)
def chk(self, u):
# 問題2: u, d といった1〜2文字の省略変数名
if u["lv"] >= self.d["min_lv"] and not self.flg:
return True
return False
# 問題3: proc という汎用動詞のみのメソッド名
def proc(self, amt):
if self.t == "fix":
return amt - self.d["val"]
return amt * (1 - self.d["val"] / 100)
# 問題4: exec は Python 組み込み関数と同名(シャドーイング)
def exec(self, u, amt):
if self.chk(u):
res = self.proc(amt)
self.flg = True
return res
return amt
def apply_coupon_to_cart(mgr, u, amt):
r = mgr.exec(u, amt)
return r
CouponMgr は「何を管理するか」しか表さず、実体が「発行済みクーポン1件」という値であることが読み取れないd, t, u, amt, res は検索性・可読性が著しく低いchk / proc は「何を確認・計算するか」が名前から欠落しているexec は Python の組み込み関数と同名で、IDE補完誤爆・可読性低下を招くflg は型も意味も名前から分からないd["min_lv"], d["val"] はキーのタイポが実行時までエラーにならない"fix" / "pct" は取りうる値の一覧がコードを読まないと分からず、タイポにも気付けないヒント(段階的開示)
ヒント1 — 方向性
CouponMgr.chk() / .proc() / .exec() という名前だけを見て、それぞれが何をするメソッドか説明できるだろうか。説明できないなら、それは「実装を読まないと使い方が分からない」名前だということ。同様に d, t, u, flg のような1〜3文字の変数名は、書いた本人以外(そして数週間後の本人)にとって検索性・可読性が著しく低い。まず「このクラス・メソッド・変数は、ドメインの言葉で言うと何と呼ぶべきか」を日本語で一度書き出してから命名すると良い。
ヒント2 — アプローチ
CouponMgr→ クラスが表す概念は「発行済みのクーポン1件」そのもの。Managerを付けずにCouponという値に振る舞い(メソッド)を持たせる設計にするchk(u)→ 「会員がこのクーポンを使えるか」を判定するクエリ。真偽値を返すのでis_接頭辞を付ける。判定条件(会員ランク)自体もCouponEligibilityという別の値オブジェクトに切り出すproc(amt)→ 「割引後の金額を計算する」クエリ。副作用がないことも名前から明確にするexec(u, amt)→ 組み込み関数と衝突する上に何をするか不明。「クーポンを適用して使用済みにする」というコマンドだと分かる名前にするt("fix"/"pct" という文字列)→DiscountType(StrEnum)にしてFIXED_AMOUNT/PERCENTAGEという自己文書化された値にするd["min_lv"],d["val"]→ dict のキー文字列ではなく、CouponEligibility.minimum_member_level/Coupon.discount_valueという属性名にする
ヒント3 — コードの骨格
class DiscountType(StrEnum):
FIXED_AMOUNT = "fixed_amount"
PERCENTAGE = "percentage"
@dataclass(frozen=True, slots=True)
class Member:
member_level: int
@dataclass(frozen=True, slots=True)
class CouponEligibility:
minimum_member_level: int
def is_satisfied_by(self, member: Member) -> bool: ...
@dataclass(slots=True)
class Coupon:
discount_type: DiscountType
discount_value: float
eligibility: CouponEligibility
is_redeemed: bool = False
def is_redeemable_by(self, member: Member) -> bool: ...
def calculate_discounted_amount(self, original_amount: int) -> int: ...
def redeem(self, member: Member, original_amount: int) -> int: ...
問題点分析(7点)
| # | 問題点 | 分類 | 改善方法 |
|---|---|---|---|
| 1 | クラス名が Manager 接尾辞(技術駆動命名) | 技術駆動命名 Ch11 | Coupon(実体そのものの名詞) |
| 2 | 1〜3文字の省略変数名(d, t, u, amt, res) | 省略命名 Ch11 | eligibility / member / original_amount 等 |
| 3 | 汎用動詞のみのメソッド名(chk, proc) | 目的駆動命名 Ch11 | is_redeemable_by / calculate_discounted_amount |
| 4 | 組み込み関数と同名の exec | 名前衝突 Ch11 | redeem(ドメイン語彙) |
| 5 | 真偽値 flg に接頭辞なし | 目的駆動命名 Ch11 | is_redeemed |
| 6 | dict キー min_lv / val が意味不明 | 値オブジェクト Ch3 | CouponEligibility.minimum_member_level / Coupon.discount_value |
| 7 | "fix"/"pct" というマジック文字列 | マジック文字列 Ch8 | DiscountType(StrEnum) |
命名リファクタ図(SVG)
模範解答
class CouponMgr:
def __init__(self, d, t):
self.d = d # クーポン設定(dict): {"min_lv": int, "val": float}
self.t = t # "fix" or "pct"
self.flg = False
def chk(self, u):
# ユーザーがクーポンを使えるか判定
if u["lv"] >= self.d["min_lv"] and not self.flg:
return True
return False
def proc(self, amt):
# 割引金額を計算
if self.t == "fix":
return amt - self.d["val"]
return amt * (1 - self.d["val"] / 100)
def exec(self, u, amt):
if self.chk(u):
res = self.proc(amt)
self.flg = True
return res
return amt
def apply_coupon_to_cart(mgr, u, amt):
r = mgr.exec(u, amt)
return r
"""coupon_issuance.py — キャンペーンクーポン発行・適用
良いコード・悪いコードで学ぶ設計入門(改訂新版)
Ch11: 名前設計 — 目的駆動命名、技術駆動命名の回避、省略しない
Ch3 : 完全コンストラクタ・値オブジェクト
Ch8 : StrEnum によるマジック文字列排除
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
class DiscountType(StrEnum):
"""クーポンの割引方式。"""
FIXED_AMOUNT = "fixed_amount"
PERCENTAGE = "percentage"
@dataclass(frozen=True, slots=True)
class Member:
"""会員。"""
member_level: int
@dataclass(frozen=True, slots=True)
class CouponEligibility:
"""クーポン適用に必要な会員条件(値オブジェクト)。"""
minimum_member_level: int
def is_satisfied_by(self, member: Member) -> bool:
"""会員が適用条件を満たすか判定する。
Args:
member: 判定対象の会員。
Returns:
条件を満たすなら True。
"""
return member.member_level >= self.minimum_member_level
@dataclass(slots=True)
class Coupon:
"""発行済みクーポン1件。"""
discount_type: DiscountType
discount_value: float # 定額(FIXED_AMOUNT)なら円、定率(PERCENTAGE)ならパーセント
eligibility: CouponEligibility
is_redeemed: bool = False
def is_redeemable_by(self, member: Member) -> bool:
"""会員がこのクーポンを使用可能か判定する(クエリ・副作用なし)。
Args:
member: 判定対象の会員。
Returns:
使用可能なら True。
"""
return self.eligibility.is_satisfied_by(member) and not self.is_redeemed
def calculate_discounted_amount(self, original_amount: int) -> int:
"""割引後の金額を計算する(クエリ・副作用なし)。
Args:
original_amount: 割引適用前の金額(円)。
Returns:
割引後の金額(円)。
"""
if self.discount_type is DiscountType.FIXED_AMOUNT:
return original_amount - int(self.discount_value)
return int(original_amount * (1 - self.discount_value / 100))
def redeem(self, member: Member, original_amount: int) -> int:
"""クーポンを適用し、使用済みにする(コマンド・副作用あり)。
Args:
member: 適用対象の会員。
original_amount: 割引適用前の金額。
Returns:
割引適用後の金額。適用不可の場合は original_amount をそのまま返す。
"""
if not self.is_redeemable_by(member):
return original_amount
discounted_amount = self.calculate_discounted_amount(original_amount)
self.is_redeemed = True
return discounted_amount
def apply_coupon_to_cart(coupon: Coupon, member: Member, original_amount: int) -> int:
"""カート画面でクーポンを適用する。"""
return coupon.redeem(member, original_amount)
member = Member(member_level=3)
coupon = Coupon(
discount_type=DiscountType.PERCENTAGE,
discount_value=10,
eligibility=CouponEligibility(minimum_member_level=2),
)
print(apply_coupon_to_cart(coupon, member, 5000)) # -> 4500
print(apply_coupon_to_cart(coupon, member, 5000)) # -> 5000(使用済みのため適用されない)
# 会員ランクが条件を満たさない場合
low_level_member = Member(member_level=1)
another_coupon = Coupon(
discount_type=DiscountType.FIXED_AMOUNT,
discount_value=500,
eligibility=CouponEligibility(minimum_member_level=2),
)
print(another_coupon.is_redeemable_by(low_level_member)) # -> False
print(apply_coupon_to_cart(another_coupon, low_level_member, 3000)) # -> 3000(適用不可)
| ポイント | 適用した設計原則/パターン | 書籍対応章 |
|---|---|---|
CouponMgr → Coupon(Manager接尾辞の排除) | 目的駆動命名・技術駆動命名の回避 | Ch11 |
chk/proc/exec → is_redeemable_by/calculate_discounted_amount/redeem | 省略しない命名・動詞+目的語で意図を表現 | Ch11 |
flg → is_redeemed(真偽値の接頭辞) | 目的駆動命名 | Ch11 |
t("fix"/"pct") → DiscountType(StrEnum) | マジック文字列の排除 | Ch8 |
d["min_lv"] → CouponEligibility.minimum_member_level | 値オブジェクト・完全コンストラクタ | Ch3 |
Coupon/Member を dataclass(frozen/slots) 化 | 不変の活用 | Ch3/Ch4 |
# tests/test_coupon_issuance.py
import pytest
from coupon_issuance import Coupon, CouponEligibility, DiscountType, Member, apply_coupon_to_cart
class TestCouponEligibility:
def test_is_satisfied_by_true_when_level_meets_minimum(self):
eligibility = CouponEligibility(minimum_member_level=2)
assert eligibility.is_satisfied_by(Member(member_level=3)) is True
def test_is_satisfied_by_false_when_level_below_minimum(self):
eligibility = CouponEligibility(minimum_member_level=2)
assert eligibility.is_satisfied_by(Member(member_level=1)) is False
class TestCoupon:
def setup_method(self):
self.member = Member(member_level=3)
self.coupon = Coupon(
discount_type=DiscountType.PERCENTAGE,
discount_value=10,
eligibility=CouponEligibility(minimum_member_level=2),
)
def test_calculate_discounted_amount_percentage(self):
assert self.coupon.calculate_discounted_amount(5000) == 4500
def test_calculate_discounted_amount_fixed_amount(self):
coupon = Coupon(
discount_type=DiscountType.FIXED_AMOUNT,
discount_value=500,
eligibility=CouponEligibility(minimum_member_level=2),
)
assert coupon.calculate_discounted_amount(5000) == 4500
def test_redeem_marks_coupon_as_redeemed(self):
apply_coupon_to_cart(self.coupon, self.member, 5000)
assert self.coupon.is_redeemed is True
def test_redeem_twice_does_not_apply_discount_again(self):
apply_coupon_to_cart(self.coupon, self.member, 5000)
result = apply_coupon_to_cart(self.coupon, self.member, 5000)
assert result == 5000
def test_redeem_rejects_member_below_minimum_level(self):
low_level_member = Member(member_level=1)
result = apply_coupon_to_cart(self.coupon, low_level_member, 5000)
assert result == 5000
assert self.coupon.is_redeemed is False
def test_member_is_frozen(self):
with pytest.raises(Exception):
self.member.member_level = 99 # type: ignore
ポイント解説
CouponMgr は「クーポンを管理する何か」としか読めず、実体が「発行済みクーポン1件」という値であることが名前から分からない。Coupon という名詞そのものにメソッドを持たせることで、クラスの責務と実体が一致する。chk / proc / exec は「何を確認・処理・実行するのか」が欠落した汎用動詞の省略形。is_redeemable_by / calculate_discounted_amount / redeem に変えることで、呼び出し側はドキュメントを読まずに用途を推測できる。flg は型も意味も名前から読み取れない。is_redeemed にすることで、真偽値であることとその意味の両方が一目で分かる。exec は Python の組み込み関数と同名であり、シャドーイングによる混乱・IDEの補完誤爆を招く。ドメイン語彙の redeem(クーポンを引き換える)に置き換える。"fix" / "pct" という生文字列はタイポに気付けず、取りうる値の一覧もコードを読まないと分からない。DiscountType.FIXED_AMOUNT / PERCENTAGE にすることで、IDE補完・型チェッカーの両方で誤りを検出できる。d["min_lv"] はキーのタイポが実行時までエラーにならない。CouponEligibility.minimum_member_level という属性にすれば、存在しない属性へのアクセスは静的解析・実行時エラーの両方で即座に検出される。実務への応用
MOps チームのキャンペーンクーポン機能では、CouponMgr のような汎用名のクラスが1つのファイルに肥大化しがちで、新しい割引種別(送料無料クーポン、ポイント倍率クーポン等)を追加するたびに chk / proc の中に if 分岐が増えていく。クラス名・メソッド名を目的駆動にリファクタリングしておくことで、コードレビューでも「この Coupon.redeem() は本当に副作用があるべきか」といった責務の議論がしやすくなり、命名の粒度がそのままレビュー観点になる。
社内のコーディング規約に「Manager / Helper / Util / Data / Info を新規クラス名に使う場合はレビューで用途を説明する」というルールを追加すると、この種の技術駆動命名が入り込む前に防止できる。
flg_xxx, val, info)にも同じ問題が起きやすく、この命名レビューの観点はそのまま横展開できる。
今日のまとめ
次のステップ
- 発展問題: 割引種別が「定額」「定率」に加えて「送料無料」「特定商品限定」に増えた場合、
Coupon.calculate_discounted_amount()のif/else分岐がどう肥大化するかを検討し、第8章のポリシーパターン/ストラテジパターンで解消するリファクタリングを行え。 - 参考: 「良いコード・悪いコードで学ぶ設計入門(改訂新版)」Ch11(名前設計)/ Ch3(カプセル化)/ Ch8(マジックナンバー・条件分岐)/ Python
enum.StrEnum公式ドキュメント