コーディング設計 弱点補強 — Money値オブジェクト × Decimal

2026-04-12 (Day 8) A: コーディング設計 ★★★☆☆ 値オブジェクト / Decimal / frozen dataclass

概要

💰

値オブジェクト(Value Object)

「金額」は単なる数値ではなく、独自の不変条件(非負・通貨・演算ルール)を持つ概念。frozen=True で不変性を保証する。

🔢

Decimal による精度保証

float0.1 + 0.2 == 0.30000000000000004 問題を防ぐ。ECサイトの金額計算では必ず Decimal を使う。

❄️

frozen=True + slots=True

不変性をランタイムで強制(FrozenInstanceError)。slots=True でメモリ効率も向上(約40〜50%削減)。

🎯

暗黙変換の排除

if discount_rate > 1: discount_rate /= 100 のような暗黙変換はバグの温床。入力規約を明示し、範囲外は ValueError で弾く。

問題

ECサイトの販促システムで、クーポン割引と消費税の計算処理を担当しています。以下の「悪いコード」を読み、問題点を指摘した上で改善してください。

制約・前提条件

  • Python 3.12 を使用
  • 金額は日本円(整数、または小数第2位まで)
  • 割引率は 0.0〜1.0 の範囲(例: 20%オフ → 0.2)
  • 消費税率は 0.1(10%)固定で問題なし
期待する回答形式: (1) 問題点の列挙、(2) 改善後コード、(3) 実行例、(4) 適用した設計パターン名

悪いコード (Before)

このコードには 3つの根本的な問題 が隠れています。見つけてみてください。
def apply_discount(price, discount_rate):
    if discount_rate > 1:
        discount_rate = discount_rate / 100  # ← 暗黙変換(問題1)
    return price * (1 - discount_rate)

def calculate_tax(price, tax_rate=0.1):     # ← float(問題2)
    return price * (1 + tax_rate)

# 呼び出し側
price = 1980
discounted = apply_discount(price, 20)   # 20%オフのつもり
total = calculate_tax(discounted)
print(f"合計: {total}円")  # 合計: 1713.6円 ← 端数が出る(問題3)

# また「金額」という概念が裸の数値(問題4)
# バリデーション・振る舞いが散逸している

ヒント(段階的開示)

ヒント1 — 方向性
「金額」という概念をただの数値(int/float)で扱うことが根本的な問題です。 「金額」は独自のルール(負にならない・精度・通貨)を持つ概念 → どう表現すべきでしょうか?
ヒント2 — アプローチ(値オブジェクト)

値オブジェクト(Value Object)パターンを使います。

  • dataclass + frozen=True + __post_init__ でバリデーション付きの不変クラスを作る
  • 浮動小数点(float)で金額計算すると誤差が出るため decimal.Decimal を使う
  • 割引適用・税計算は Money クラスのメソッドとして持たせる(Tell, Don't Ask)
ヒント3 — 目指す骨格
@dataclass(frozen=True, slots=True)
class Money:
    amount: Decimal
    currency: str = "JPY"

    def __post_init__(self) -> None:
        # バリデーション: 負の金額を拒否
        ...

    def apply_discount(self, rate: Decimal) -> "Money":
        # rate が 0〜1 の範囲外ならエラー
        ...

    def apply_tax(self, rate: Decimal = Decimal("0.1")) -> "Money":
        # 小数点以下切り捨てで返す
        ...

問題点分析

#問題点分類影響改善方法
1 discount_rate > 1 の暗黙変換 暗黙変換 入力規約が不明瞭・バグの温床。20 を渡すと 0.2 に変換されるが意図が見えない 入力規約を明示し、範囲外は ValueError で弾く
2 float 演算による誤差 精度問題 1980 * 0.8 * 1.1 = 1742.3999... のように端数が出る Decimal + ROUND_DOWN で端数処理を明示
3 「金額」を裸の数値で扱う(プリミティブ型執着) 設計問題 バリデーション・振る舞いが関数に散逸する。負の金額も表現できてしまう Money 値オブジェクト化

Money 設計図 — 値オブジェクトパターン

Money @dataclass(frozen=True, slots=True) Fields amount: Decimal (>= 0) currency: str = "JPY" Methods apply_discount(rate) -> Money apply_tax(rate) -> Money __add__(other) -> Money ❄️ frozen=True: 変更不可 / 新インスタンスを返す 不変チェーン(コピーオンライト) price Money(1980) apply_discount (0.20) discounted Money(1584) apply_tax (0.10) total Money(1742) Decimal vs float 比較 float: 1980 * 0.8 * 1.1 = 1742.4000000001 Decimal: 1980 * 0.8 * 1.1 = 1742 ← 正確

模範解答

✗ Before(問題あり)
def apply_discount(price, discount_rate):
    if discount_rate > 1:
        discount_rate = discount_rate / 100
    return price * (1 - discount_rate)

def calculate_tax(price, tax_rate=0.1):
    return price * (1 + tax_rate)

# 呼び出し
discounted = apply_discount(1980, 20)
total = calculate_tax(discounted)
# 合計: 1713.6円 ← 端数
✓ After(値オブジェクト)
price = Money(Decimal("1980"))
discounted = price.apply_discount(Decimal("0.20"))
total = discounted.apply_tax()
# 元値:   1,980JPY
# 割引後: 1,584JPY
# 税込:   1,742JPY ← 正確

改善後コード(全文)

from dataclasses import dataclass
from decimal import Decimal, ROUND_DOWN


# 名前付き定数
DEFAULT_TAX_RATE = Decimal("0.10")
ZERO = Decimal("0")
ONE = Decimal("1")


@dataclass(frozen=True, slots=True)
class Money:
    """金額を表す値オブジェクト。不変・バリデーション済み。

    Args:
        amount: 金額(Decimal)
        currency: 通貨コード(デフォルト: "JPY")

    Raises:
        ValueError: 金額が負の場合
        TypeError: amount が Decimal でない場合
    """

    amount: Decimal
    currency: str = "JPY"

    def __post_init__(self) -> None:
        if not isinstance(self.amount, Decimal):
            raise TypeError(f"amount は Decimal である必要があります: {type(self.amount)}")
        if self.amount < ZERO:
            raise ValueError(f"金額は0以上でなければなりません: {self.amount}")

    def apply_discount(self, rate: Decimal) -> "Money":
        """割引を適用した新しい Money を返す。

        Args:
            rate: 割引率(0.0〜1.0。例: 20%オフ → Decimal("0.2"))

        Raises:
            ValueError: rate が 0〜1 の範囲外の場合
        """
        if not (ZERO <= rate <= ONE):
            raise ValueError(f"割引率は 0〜1 の範囲でなければなりません: {rate}")
        new_amount = (self.amount * (ONE - rate)).quantize(ONE, rounding=ROUND_DOWN)
        return Money(new_amount, self.currency)

    def apply_tax(self, rate: Decimal = DEFAULT_TAX_RATE) -> "Money":
        """消費税を加算した新しい Money を返す(小数点以下切り捨て)。"""
        new_amount = (self.amount * (ONE + rate)).quantize(ONE, rounding=ROUND_DOWN)
        return Money(new_amount, self.currency)

    def __add__(self, other: "Money") -> "Money":
        if self.currency != other.currency:
            raise ValueError(f"通貨単位が異なる金額は加算できません: {self.currency} vs {other.currency}")
        return Money(self.amount + other.amount, self.currency)

    def __str__(self) -> str:
        return f"{self.amount:,}{self.currency}"


# --- 実行例 ---
if __name__ == "__main__":
    price = Money(Decimal("1980"))
    discounted = price.apply_discount(Decimal("0.20"))  # 20%オフ
    total = discounted.apply_tax()
    print(f"元値:   {price}")       # 元値:   1,980JPY
    print(f"割引後: {discounted}")  # 割引後: 1,584JPY
    print(f"税込:   {total}")       # 税込:   1,742JPY(1584 * 1.1 = 1742.4 → 切り捨て)

ポイント解説

1 float で金額計算しない
0.1 + 0.2 == 0.30000000000000004 のように浮動小数点は誤差が出る。金額には必ず decimal.Decimal を使う。quantize() + ROUND_DOWN で端数処理を明示する。
2 値オブジェクト(Value Object)パターン
「金額」は単なる数値ではなく、独自の不変条件(非負・通貨・演算ルール)を持つ概念。frozen=True で不変性を保証し、__post_init__ でバリデーションを集約することで、「不正な金額オブジェクトは生成できない」という契約を実現する。
3 slots=True でメモリ効率化
__slots__ を有効にすると __dict__ が不要になり、大量のインスタンスを生成するEC系バッチ処理でメモリ使用量が削減される(目安: 40〜50%削減)。
4 不変パターン(コピーオンライト)
apply_discount() / apply_tax() は元の Money を変えず、新しい Money インスタンスを返す。チェーン呼び出しが可能になり、副作用ゼロのコードになる。

悪いコードの問題点 まとめ

問題詳細
暗黙変換 discount_rate > 1 の暗黙変換 → 入力規約が不明瞭・バグの温床
精度問題 float 演算 → 金額に誤差(1980 * 0.8 * 1.1 = 1742.3999...
設計問題 「金額」という概念を裸の数値で扱う → バリデーション・振る舞いが散逸

実務への応用

MOpsの販促クーポンシステムで Money 値オブジェクトを導入すると:
  • クーポン割引額の計算ロジックがサービス層に散在せず Money クラスに集約される
  • BigQuery へ出力する金額カラムも DecimalNUMERIC 型でそのままマッピング可能
  • Pydantic v2 との相性も良く condecimalAnnotated[Decimal, Gt(0)] で API バリデーションも統一できる

次のステップ

発展問題1: Money に対応する Quantity(個数)値オブジェクトを作り Money * Quantity → Money の演算子を実装する
発展問題2: 複数通貨(JPY/USD)の換算レートを持つ ExchangeRate クラスを追加する
  • 参考: 「良いコード・悪いコードで学ぶ設計入門」第7章「コレクションオブジェクト」・第3章「クラスで表現する」

今日のまとめ

「金額」を float や裸の int で扱うのは実務バグの温床。

Decimal + frozen dataclass の値オブジェクトで「不正な状態を表現できない型」を作ることが、ECサイト系ドメインロジックの堅牢化につながる。

apply_discount() / apply_tax() は不変パターン(新インスタンスを返す)でチェーン呼び出しを実現する。

自己評価

自分の回答

気づき・メモ