業務日付を抽象化、その後具象から守る: 抽象オブジェクト&プロバイダーパターン

こんにちは、ニーリーの佐古です。

現在開発速度や開発者体験の向上のため、取り組みの諸々を遂行しています。

ニーリーアドベントカレンダー 19日目の記事です。
トレンドや流れを無視して今回は「よく使うけどなぜかパッと見つかるところで資料化されていない設計パターン」についてのメモです。ご利用の際のタイムゾーンはよきように。

はじめに

アプリケーションのあちこちで日付判定が重複したり、バッチ実行時にシステム日付をいじったり、UT追加の度に日付取得処理/判定処理をモックしたり、それが原因でUTがflakyになって煩悶したりしていますか?私は割としています。ということで今回はその対策について。

ステップ1: 業務日時の抽象化

業務に関する日時判断・計算を行う際はオブジェクトを定義して各種判定を楽にしたいものです。

from __future__ import annotations
from dataclasses import dataclass
from datetime import date
import calendar

@dataclass(frozen=True)
class BusinessDateTime:
    """
    業務日付を表す値オブジェクト。
    システム日付とは独立して扱われ、業務ロジック側から
    datetime API を直接参照させないための抽象化レイヤ。
    """
    value: datetime

    @staticmethod
    """
    Python の date から業務日付オブジェクトを生成する。
    """
    def from_datetime(dt: datetime) -> "BusinessDateTime":
        return BusinessDateTime(dt)

    def is_end_of_month(self) -> bool:
        """
        この業務日付が「その月の最終日」であるかを判定する。

        業務上の締め処理や請求ロールなど、多くの機能が
        「月末日かどうか」に依存するため、ロジック側で
        calendar.monthrange を直接扱わず、このメソッドを利用する。
        """
        last_day = calendar.monthrange(self.value.year, self.value.month)[1]
        return self.value.day == last_day

簡単な例として月末判定を追加しましたが、各アプリケーションのコンテキストで必要な判定はメソッドにしてこちらに寄せられます。datetimeを直接扱わなくてよいような機能を積みましょう。

ステップ2: 業務日時をプロバイダー経由で取得する

残る問題

さてこれでdatetimeを直接扱わなくてよく……はなっていません。

今日以外を取得する

例えば「昨日」を取得したいとき。

from datetime import datetime, timedelta, timezone

def get_yesterday_business_date() -> BusinessDate:
    today_raw = datetime.now(timezone.utc).date()
    yesterday_raw = today_raw - timedelta(days=1)
    return BusinessDate.from_date(yesterday_raw)

思いっきりdatetime計算をしています。こんなはずではなかった。

取得処理を抽象化する

というわけで取得処理の方も抽象化しましょう。

class BusinessDateProvider:
    """
    業務日付を供給するプロバイダー。

    「システム日付(UTC)」を基準にtoday / from_today を提供する。
    """

    def today(self) -> BusinessDate:
        """
        現在の業務日付を返す。
        """
        raw = datetime.now(timezone.utc).date()
        return BusinessDate.from_date(raw)

    def from_today(self, offset_days: int) -> BusinessDate:
        """
        現在の業務日付から offset_days 日だけ進めた/戻した日を返す。
        負の値で過去日、正の値で未来日。
        """
        base = datetime.now(timezone.utc).date()
        shifted = base + timedelta(days=offset_days)
        return BusinessDate.from_date(shifted)

これで取得に関してもdatetimeを生で扱わなくてよくなりました。

「現在」を固定したい場合がある

さて、バッチ(特にリラン)やユニットテストを実行する際には「現在」を固定できた方が便利なケースがあります。ユニットテストで日付取得処理をいちいちモックするのは面倒ですし、場合によっては並列テストが壊れます。かといって相対日付だけでテストすると検証内容が分かりにくくなりますし実装と同じバグを仕込んでしまう可能性もあります。であればそういったケースではプロバイダーを「固定された現在を取得できる」実装に差し替えてやるのがよさそうです。

まずはインターフェースを

差し替えなのでインターフェースと実装を分離します。

from __future__ import annotations
from typing import Protocol


class BusinessDateProvider(Protocol):
    """
    業務日付を供給するプロバイダーのインターフェース。

    - today(): 「業務上の今日」
    - from_today(offset_days): 今日からの相対日

    業務ロジックは datetime を直接触らず、
    このインターフェースだけに依存する。
    """

    def today(self) -> BusinessDate:
        """
        現在の業務日付を返す。
        実装により「現在」の定義は異なりうる。
        """
        ...

    def from_today(self, offset_days: int) -> BusinessDate:
        """
        現在の業務日付から offset_days 日だけ進めた/戻した日を返す。
        負の値で過去日、正の値で未来日。
        """
        ...

実装1: 通常(リアルタイム)のプロバイダー

元々の実装はこちらに移します。

from datetime import datetime, timedelta, timezone


class SystemBusinessDateProvider:
    """
    システムの現在日時(UTC)を基準に業務日付を供給する実装。

    本番環境など、「特別な指定がない通常の今日」を扱う用途を想定する。
    datetime/timedelta に触れてよいのはこのクラスの中だけにする。
    """

    def today(self) -> BusinessDate:
        raw = datetime.now(timezone.utc).date()
        return BusinessDate.from_date(raw)

    def from_today(self, offset_days: int) -> BusinessDate:
        base = datetime.now(timezone.utc).date()
        shifted = base + timedelta(days=offset_days)
        return BusinessDate.from_date(shifted)

実装2: 固定値のプロバイダー

今回のニーズを満たすプロバイダーの実装も追加します。

from datetime import date, timedelta


class FixedBusinessDateProvider:
    """
    任意に指定した日付を「業務上の今日」として扱うプロバイダー。

    - UT: 境界日(末日・月初など)を固定してテストしたいとき
    - バッチ: 「処理日」を外部から指定して再実行したいとき

    に利用することを想定している。
    """

    def __init__(self, base_date: date) -> None:
        self._base_date = base_date

    def today(self) -> BusinessDate:
        return BusinessDate.from_date(self._base_date)

    def from_today(self, offset_days: int) -> BusinessDate:
        shifted = self._base_date + timedelta(days=offset_days)
        return BusinessDate.from_date(shifted)

これで現在の固定が可能になりました。あとは

  • 検証環境用に設定ファイルから「今日」を吸い上げる機能を付けたり
  • DIコンテナに差し替えを任せたり
  • この取得処理自体をドメインサービスとして定義しちゃったり

と、好きなように利用可能です。

さいごに

作るのは簡単ですが使ってもらうのがミソで、しかも今回のようなパターンでは「やっちゃいけないこと(= 業務レイヤでdatetimeを生で扱う)」の普及も必要になります。実装よりこれがはるかに大変なので、そういうロールの人は頑張りましょう。レビュー用のLLMなど使うと幸せになれるのではないでしょうか。また、プロダクトの立ち上げ時からこのくらいの機構は導入しておいても罰は当たらないでしょう。ということで現在開発中の新基盤ではこれに類似した機能を既に導入しました。どうなるか楽しみです。