A コーディング — 目的駆動命名 × 技術駆動命名の回避 × StrEnum によるマジック文字列排除(キャンペーンクーポン発行 Bad→Good Ch11/Ch3/Ch8)

2026-07-20 (Day 106) 月曜 コーディング ★★★★☆ Python 3.12 / dataclass(frozen, slots) / StrEnum 良いコード設計入門 Ch11 / Ch3 / Ch8

概要

🏷️

技術駆動命名の回避(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・インラインコメント・名前付き定数を含めること
期待する回答形式: 問題点の列挙(番号付き)+ 改善後コード(Google スタイル docstring・インラインコメント・名前付き定数含む)+ 実行例(input→output)+ 適用した設計パターン名と書籍対応章

悪いコード (Before)

このコードには 7つの設計上の問題 が隠れています。見つけてみてください。
bad_coupon.py — 技術駆動命名 × 省略名 × 組み込み名との衝突
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
問題点サマリー(7点)
1クラス名の技術駆動命名(Ch11)CouponMgr は「何を管理するか」しか表さず、実体が「発行済みクーポン1件」という値であることが読み取れない
21〜3文字の省略変数名(Ch11)d, t, u, amt, res は検索性・可読性が著しく低い
3汎用動詞のみのメソッド名(Ch11)chk / proc は「何を確認・計算するか」が名前から欠落している
4組み込み関数との名前衝突(Ch11)exec は Python の組み込み関数と同名で、IDE補完誤爆・可読性低下を招く
5真偽値に接頭辞なし(Ch11)flg は型も意味も名前から分からない
6dict キーが意味不明な省略名(Ch3/Ch11)d["min_lv"], d["val"] はキーのタイポが実行時までエラーにならない
7マジック文字列での分岐(Ch8)"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 接尾辞(技術駆動命名)技術駆動命名 Ch11Coupon(実体そのものの名詞)
21〜3文字の省略変数名(d, t, u, amt, res)省略命名 Ch11eligibility / member / original_amount
3汎用動詞のみのメソッド名(chk, proc)目的駆動命名 Ch11is_redeemable_by / calculate_discounted_amount
4組み込み関数と同名の exec名前衝突 Ch11redeem(ドメイン語彙)
5真偽値 flg に接頭辞なし目的駆動命名 Ch11is_redeemed
6dict キー min_lv / val が意味不明値オブジェクト Ch3CouponEligibility.minimum_member_level / Coupon.discount_value
7"fix"/"pct" というマジック文字列マジック文字列 Ch8DiscountType(StrEnum)

命名リファクタ図(SVG)

CouponMgr(Before) d, t, flg 技術駆動 / 省略名 chk(u) → 何を確認するか不明 proc(amt) → 何を計算するか不明 exec(u, amt) → 組み込み関数と衝突 Ch11 命名リファクタ DiscountType(StrEnum) FIXED_AMOUNT / PERCENTAGE "fix"/"pct" のマジック値を排除 CouponEligibility minimum_member_level is_satisfied_by(member) → bool Coupon discount_type / discount_value eligibility / is_redeemed is_redeemable_by(member) → bool calculate_discounted_amount(amount) redeem(member, amount) クエリ(is_/calculate_)とコマンド(redeem)を名前で区別 Member member_level Before: 1クラスに責務とマジック値が集約 / After: 名前が責務を語る3+1クラスに分解

模範解答

Before — 技術駆動命名 × 省略名 × 組み込み名との衝突
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
After — 目的駆動命名 × StrEnum × 値オブジェクト
"""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(適用不可)
ポイント適用した設計原則/パターン書籍対応章
CouponMgrCoupon(Manager接尾辞の排除)目的駆動命名・技術駆動命名の回避Ch11
chk/proc/execis_redeemable_by/calculate_discounted_amount/redeem省略しない命名・動詞+目的語で意図を表現Ch11
flgis_redeemed(真偽値の接頭辞)目的駆動命名Ch11
t("fix"/"pct") → DiscountType(StrEnum)マジック文字列の排除Ch8
d["min_lv"]CouponEligibility.minimum_member_level値オブジェクト・完全コンストラクタCh3
Coupon/Memberdataclass(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

ポイント解説

1クラス名から「Manager」「Data」「Info」を排除する(Ch11: 名前設計)CouponMgr は「クーポンを管理する何か」としか読めず、実体が「発行済みクーポン1件」という値であることが名前から分からない。Coupon という名詞そのものにメソッドを持たせることで、クラスの責務と実体が一致する。
2メソッド名は動詞+目的語で意図を表現する(Ch11)chk / proc / exec は「何を確認・処理・実行するのか」が欠落した汎用動詞の省略形。is_redeemable_by / calculate_discounted_amount / redeem に変えることで、呼び出し側はドキュメントを読まずに用途を推測できる。
3真偽値には is_/has_ 接頭辞を付ける(Ch11)flg は型も意味も名前から読み取れない。is_redeemed にすることで、真偽値であることとその意味の両方が一目で分かる。
4組み込み名との衝突を避ける(Ch11)exec は Python の組み込み関数と同名であり、シャドーイングによる混乱・IDEの補完誤爆を招く。ドメイン語彙の redeem(クーポンを引き換える)に置き換える。
5マジック文字列を StrEnum で排除する(Ch8)"fix" / "pct" という生文字列はタイポに気付けず、取りうる値の一覧もコードを読まないと分からない。DiscountType.FIXED_AMOUNT / PERCENTAGE にすることで、IDE補完・型チェッカーの両方で誤りを検出できる。
6dict のキー文字列を値オブジェクトの属性名にする(Ch3)d["min_lv"] はキーのタイポが実行時までエラーにならない。CouponEligibility.minimum_member_level という属性にすれば、存在しない属性へのアクセスは静的解析・実行時エラーの両方で即座に検出される。

実務への応用

MOps チームのキャンペーンクーポン機能では、CouponMgr のような汎用名のクラスが1つのファイルに肥大化しがちで、新しい割引種別(送料無料クーポン、ポイント倍率クーポン等)を追加するたびに chk / proc の中に if 分岐が増えていく。クラス名・メソッド名を目的駆動にリファクタリングしておくことで、コードレビューでも「この Coupon.redeem() は本当に副作用があるべきか」といった責務の議論がしやすくなり、命名の粒度がそのままレビュー観点になる。

社内のコーディング規約に「Manager / Helper / Util / Data / Info を新規クラス名に使う場合はレビューで用途を説明する」というルールを追加すると、この種の技術駆動命名が入り込む前に防止できる。

データ基盤への横展開: dbt のモデル名や BigQuery のカラム名(例: flg_xxx, val, info)にも同じ問題が起きやすく、この命名レビューの観点はそのまま横展開できる。

今日のまとめ

Manager 接尾辞・1〜3文字の省略変数名・汎用動詞のメソッド名は、いずれも「実装を読まないと使い方が分からない」名前を生む共通パターンであり、ドメイン語彙に基づく目的駆動命名と StrEnum・値オブジェクトによるマジック値の排除によって、名前だけでコードの意図が伝わる状態に改善できる。

次のステップ

  • 発展問題: 割引種別が「定額」「定率」に加えて「送料無料」「特定商品限定」に増えた場合、Coupon.calculate_discounted_amount()if/else 分岐がどう肥大化するかを検討し、第8章のポリシーパターン/ストラテジパターンで解消するリファクタリングを行え。
  • 参考: 「良いコード・悪いコードで学ぶ設計入門(改訂新版)」Ch11(名前設計)/ Ch3(カプセル化)/ Ch8(マジックナンバー・条件分岐)/ Python enum.StrEnum 公式ドキュメント

自己評価(あとで記入)