概要
値オブジェクト(Value Object)
「金額」は単なる数値ではなく、独自の不変条件(非負・通貨・演算ルール)を持つ概念。frozen=True で不変性を保証する。
Decimal による精度保証
float の 0.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 設計図 — 値オブジェクトパターン
模範解答
✗ 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 へ出力する金額カラムも
Decimal→NUMERIC型でそのままマッピング可能 - Pydantic v2 との相性も良く
condecimalやAnnotated[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() は不変パターン(新インスタンスを返す)でチェーン呼び出しを実現する。