値オブジェクトと DTO を混ぜない
値オブジェクトは1つの概念の値と検証だけを持つ。「関係ありそうだから」と別概念の計算メソッドを足していくと、高凝集のつもりが密結合になる。逆に、層をまたいでデータを運ぶだけの DTO に業務計算を持たせるのも同じ混線。それぞれの役割を守り、別概念の値が必要なら元の値を引数で渡して導出する。
01 請求金額クラスに増殖する別概念の計算
@dataclass(frozen=True)
class InvoiceAmount:
amount: int
def withholding_tax(self) -> int:
return int(self.amount * 0.1021) # 源泉徴収は別概念
def transfer_fee(self) -> int:
return 0 if self.amount >= 30_000 else 330 # 振込手数料も別概念
def reward_point(self) -> int:
return self.amount // 100 # ポイントも別概念@dataclass(frozen=True)
class InvoiceAmount:
amount: int
def __post_init__(self) -> None:
if self.amount < 0:
raise ValueError("請求額は0以上にしてください")
@dataclass(frozen=True)
class WithholdingTax:
amount: int
@classmethod
def on(cls, invoice: InvoiceAmount) -> 'WithholdingTax':
return cls(int(invoice.amount * 0.1021))
@dataclass(frozen=True)
class TransferFee:
amount: int
@classmethod
def on(cls, invoice: InvoiceAmount) -> 'TransferFee':
return cls(0 if invoice.amount >= 30_000 else 330)「請求金額に関係する計算だから」は同居の理由にならない。源泉徴収率の改定で請求金額クラスを触るのは責務の混線。元の値をコンストラクタ引数で受け取れば、概念どうしは疎結合のまま連携できる。
02 画面表示用 DTO への業務ロジック混入
@dataclass
class InvoiceSummaryDto:
client_name: str
amount: int
issued_on: date
def apply_discount(self) -> None:
# 運搬役のはずが業務ルールを抱え、しかも自分を書き換える
self.amount = int(self.amount * 0.9)@dataclass(frozen=True)
class InvoiceSummaryDto:
client_name: str
amount: int # 割引計算は InvoiceAmount 側で済ませてから詰める
issued_on: dateDTO に計算を持たせると、同じ業務ルールが表示層とドメイン層に二重に存在し、片方だけ直す事故が起きる。「このクラスは運ぶだけ」と割り切れているかを設計の境界線にする。