ロジックを値オブジェクトへ移す
貸出判定が読みやすくなると、次に目につくのは画面表示用・督促メール用とあちこちにコピペされた延滞料の計算。金額のような業務概念は、計算ロジックごと値オブジェクトに集約すれば重複が消え、ルール変更が1か所で済む。移し替えの間はテストで挙動を固定し、壊していないことを常に確認する。
01 呼び出し側にベタ書きされた延滞料計算
def show_member_page(member: Member) -> None:
# 督促メール側にもこれと同じ計算式のコピーがある
fee = 0
for loan in member.loans:
days = (date.today() - loan.due_date).days
if days > 0:
fee += days * 10
render_member_page(member, late_fee=fee)@dataclass(frozen=True)
class Loan:
book_id: str
due_date: date
def overdue_days(self, today: date) -> int:
return max((today - self.due_date).days, 0)
@dataclass(frozen=True)
class LateFee:
FEE_PER_DAY: ClassVar[int] = 10
amount: int
@classmethod
def of(cls, loans: tuple[Loan, ...], today: date) -> "LateFee":
total_days = sum(loan.overdue_days(today) for loan in loans)
return cls(amount=total_days * cls.FEE_PER_DAY)
# 呼び出し側は1行になる
fee = LateFee.of(member.loans, today=date.today())Java の final フィールドによる不変クラスは、Python では @dataclass(frozen=True) で表現できる。today を内部で date.today() せず引数で受け取ることで計算が純粋関数になり、テストで任意の日付を差し込める。
02 テストで挙動を固定してから移し替える
def test_late_fee_is_10_yen_per_overdue_day() -> None:
today = date(2026, 6, 11)
loans = (
Loan(book_id="978-4-00-000001-9", due_date=date(2026, 6, 8)), # 3日延滞
Loan(book_id="978-4-00-000002-6", due_date=date(2026, 6, 20)), # 期限内
)
assert LateFee.of(loans, today=today).amount == 30
def test_late_fee_is_zero_when_nothing_is_overdue() -> None:
today = date(2026, 6, 11)
loans = (Loan(book_id="978-4-00-000003-3", due_date=date(2026, 6, 25)),)
assert LateFee.of(loans, today=today).amount == 0手順は「移行先のクラスのひな型を用意→テストを書く→いったん失敗させる→実装してテストを通す→呼び出し側を切り替えて古いベタ書きを消す」。テストという安全網があるからこそ、動いているコードに思い切って手を入れられる。