Domain-Driven Design Время в домене: процессы, версии правил и темпоральные данные
0%

Время в домене: процессы, версии правил и темпоральные данные

Время в домене: процессы, версии правил и темпоральные данные

Есть класс доменов, где аккуратная модель из тактических блоков и красивые типы всё равно рассыпаются, и всегда по одной причине. Страхование, биллинг, тарифы, зарплата, налоги, договоры, кадровый учёт, медицина, логистика: в каждом из них правильный ответ на вопрос бизнеса зависит не от текущего состояния, а от того, на какой момент спрашивают и что мы знали на этот момент.

Классический разрушительный код выглядит безобидно:

UPDATE tariff SET vat_rate = 0.22 WHERE id = 7;

Одна строка. Она отвечает на вопрос «какая ставка сейчас» и уничтожает ответы на вопросы «какая была в марте», «по какой ставке мы выставили счёт №4711», «почему в отчёте за первый квартал другая цифра» и «что увидит аудитор». В доменах со временем это не потеря данных — это потеря самого домена.

Статья про то, как моделировать время явно: три оси времени, эффективные даты, версии правил, корректировки задним числом, битемпоральное хранение и долгоживущие процессы.


1. Три времени, которые постоянно путают

Ось Название Что означает Кто задаёт
Время действия valid time, effective time Когда факт верен для бизнеса: «ставка действует с 1 апреля» Домен, регулятор, договор
Время знания transaction time, system time Когда мы записали факт в систему Система, автоматически
Время решения decision time Когда человек принял решение (подписал, утвердил) Домен, редко нужен

Первые две оси независимы, и именно их независимость создаёт всю сложность.

Битемпоральная плоскость: время действия факта и время знания о нём

Проверочные вопросы, по ответам на которые сразу видно, какая модель нужна:

Вопрос бизнеса Достаточно Признак
«Какой тариф сейчас?» Одно текущее значение Классический CRUD, время не нужно
«Какой тариф действует с 1 июля?» Время действия Нужны эффективные даты
«По какому тарифу мы выставили счёт в марте?» Время действия Достаточно хранить снимок в документе
«Что видел оператор 3 марта, когда одобрял заявку?» Оба времени Нужна битемпоральность
«Почему отчёт за март, построенный вчера, отличается от построенного в апреле?» Оба времени Нужна битемпоральность
«Кто и когда решил поменять ставку?» Время решения + аудит Отдельное понятие в домене

Практическое правило. Битемпоральность стоит дорого: сложнее запросы, больше данных, выше порог входа. Вводите её, только когда есть регуляторное или расчётное требование воспроизвести прошлое знание. Во всех остальных случаях хватает времени действия плюс снимка значений в документе.


2. Эффективные даты: период как объект-значение

Базовый приём — вместо «текущего значения» хранить последовательность версий с периодами действия. Первое, что для этого нужно, — период как полноценный объект-значение с замкнутыми операциями (см. гибкий дизайн).

from dataclasses import dataclass
from datetime import date

FOREVER = date(9999, 12, 31)


@dataclass(frozen=True)
class EffectivePeriod:
    """Полуинтервал [start, end): начало включительно, конец исключительно.
    Полуинтервалы стыкуются без дыр и без перекрытий — с включающими границами
    вы обречены на вечные ошибки в один день."""
    start: date
    end: date = FOREVER

    def __post_init__(self) -> None:
        if self.start >= self.end:
            raise ValueError("период должен быть непустым")

    def contains(self, moment: date) -> bool:
        return self.start <= moment < self.end

    def overlaps(self, other: "EffectivePeriod") -> bool:
        return self.start < other.end and other.start < self.end

    def truncated_at(self, moment: date) -> "EffectivePeriod":
        """Закрыть период задним числом — операция замкнута: период -> период."""
        return EffectivePeriod(self.start, moment)

Теперь версия правила — это пара «период + значение», а набор версий — понятие с собственным инвариантом.

@dataclass(frozen=True)
class VatVersion:
    period: EffectivePeriod
    rate: Decimal


class VatSchedule:
    """Расписание ставок НДС. Инвариант: версии не пересекаются
    и покрывают время без дыр — иначе появится дата без ставки."""

    def __init__(self, versions: list[VatVersion]) -> None:
        ordered = sorted(versions, key=lambda v: v.period.start)
        for prev, nxt in zip(ordered, ordered[1:]):
            if prev.period.end != nxt.period.start:
                raise ValueError(
                    f"разрыв или перекрытие между {prev.period.end} и {nxt.period.start}")
        self._versions = tuple(ordered)

    def rate_on(self, moment: date) -> Decimal:
        """Версии упорядочены, поэтому здесь уместен бинарный поиск:
        O(log n) по числу версий, память O(1). Для десятка версий сойдёт и перебор."""
        idx = bisect_right([v.period.start for v in self._versions], moment) - 1
        if idx < 0 or not self._versions[idx].period.contains(moment):
            raise ValueError(f"на дату {moment} ставка не определена")
        return self._versions[idx].rate

    def scheduled_change(self, new_rate: Decimal, since: date) -> "VatSchedule":
        """Запланировать изменение: последняя версия усекается, добавляется новая.
        Чистая функция — старое расписание остаётся валидным."""
        head = [v for v in self._versions if v.period.start < since]
        head[-1] = VatVersion(head[-1].period.truncated_at(since), head[-1].rate)
        return VatSchedule(head + [VatVersion(EffectivePeriod(since), new_rate)])

Обратите внимание на две вещи. Во-первых, инвариант «без дыр и перекрытий» проверяется в конструкторе: расписание с дырой невозможно создать. Во-вторых, scheduled_change — не мутация, а порождение новой версии расписания; плановое изменение ставки, которое подписали в марте, а применят в июле, укладывается в модель естественно и не требует крона «включить ставку первого числа».

Периоды удобно смотреть глазами на временной шкале — это же изображение стоит держать в документации домена:

Из картинки сразу видно то, что не видно в коде: границы версий разных правил не совпадают. Отсюда следует, что «версия конфигурации системы» как единое понятие — плохая идея; версионируется каждое правило отдельно, своим расписанием.


3. Правило всегда параметризовано датой

Из эффективных дат следует жёсткое требование к сигнатурам домена, которое в статье про тактические блоки упоминалось как ошибка datetime.now() внутри домена. Здесь оно превращается в фундамент.

# ПЛОХО: результат зависит от момента запуска, тест невоспроизводим,
# перерасчёт за март даст мартовский ответ только если запустить в марте.
def total_with_vat(order: Order) -> Money:
    return order.subtotal * (1 + vat_schedule.rate_on(date.today()))


# ХОРОШО: дата применения правила — часть постановки задачи.
def total_with_vat(order: Order, vat: VatSchedule, applicable_on: date) -> Money:
    return order.subtotal * (1 + vat.rate_on(applicable_on))

Дальше начинается собственно моделирование: какая именно дата является applicable_on — это вопрос к эксперту, а не к разработчику. Для НДС в России это дата отгрузки, для страховой премии — дата наступления ответственности, для тарифа связи — дата начала расчётного периода, для зарплаты — последний день месяца. Ошибка в выборе даты даёт расхождения, которые обнаруживаются спустя квартал в отчётности.

Практическое следствие: у агрегата почти всегда появляется явное поле вроде service_date, accrual_date, effective_on — и оно не равно created_at. Смешение этих двух полей — пожалуй, самая частая ошибка в биллинговых системах.

Текущее время в систему поставляется портом — и тестовый дублёр к нему занимает пять строк, после чего вся флейкость тестов вокруг дат исчезает:

class Clock(Protocol):
    def today(self) -> date: ...
    def now(self) -> datetime: ...


class FixedClock:
    def __init__(self, moment: datetime) -> None: self._moment = moment
    def today(self) -> date: return self._moment.date()
    def now(self) -> datetime: return self._moment

4. Задним числом: корректировка — это доменное понятие

Ключевой момент, который отличает зрелую модель от наивной. Когда выясняется, что в январе ставка была не 20 %, а 19 %, есть два пути.

Наивный: исправить историю. UPDATE, пересчитать, показать новые цифры. Простой, быстрый и неверный: старые отчёты перестают воспроизводиться, аудит невозможен, а клиенты видят, что выставленный счёт изменился задним числом.

Доменный: прошлое неизменяемо, изменение вносится новым фактом. Ровно та же логика, что у доменных событий: факт нельзя отменить, его можно только компенсировать. Так работает бухгалтерия последние 500 лет — сторнирующая проводка вместо удаления ошибочной.

В коде это выражается тем, что корректировка становится отдельным понятием домена — со своим именем, своим документом и своими правилами, а не техническим апдейтом:

@dataclass(frozen=True)
class Adjustment:
    """Корректировка — самостоятельный факт, а не правка старого.
    У неё своя дата действия (период, за который пересчитали)
    и своя дата документа (когда мы это сделали)."""
    id: AdjustmentId
    corrects: DocumentId          # ссылка на исходный документ, не замена ему
    period: EffectivePeriod       # за какой период пересчёт
    delta: Money                  # знак важен: и доначисление, и возврат
    reason: AdjustmentReason      # понятие домена: ошибка ставки, ретро-скидка, суд
    issued_on: date               # когда оформили корректировку

Три правила, которые стоит принять как аксиомы:

  1. Никогда не меняйте выпущенный документ. Счёт, полис, накладная, начисление — выпущенные, они неизменяемы. Есть только корректирующие документы.
  2. У корректировки есть причина из словаря домена. reason: str со свободным текстом через год превращается в свалку; эксперт всегда может перечислить конечный список оснований.
  3. Перерасчёт — процесс, а не функция. Он затрагивает много документов, идёт долго, может быть частично неуспешным и требует отчёта. Оформляйте его как долгоживущий процесс (см. раздел 6), а не как обработчик HTTP-запроса.

5. Битемпоральное хранение

Когда обе оси действительно нужны, схема выглядит так: у строки две пары границ.

Запрос «какая ставка действовала 15 февраля по данным на 1 марта» становится обычным WHERE по двум диапазонам:

SELECT rate
  FROM vat_rate_history
 WHERE jurisdiction = 'RU'
   AND valid_from <= DATE '2026-02-15' AND valid_to > DATE '2026-02-15'   -- время действия
   AND tx_from    <= TIMESTAMPTZ '2026-03-01'                              -- время знания
   AND (tx_to IS NULL OR tx_to > TIMESTAMPTZ '2026-03-01');

В PostgreSQL инвариант «версии не пересекаются» можно переложить на базу — это единственный способ гарантировать его при конкурентной записи (см. транзакции и изоляцию):

CREATE EXTENSION IF NOT EXISTS btree_gist;

ALTER TABLE vat_rate_history
  ADD CONSTRAINT vat_no_overlap
  EXCLUDE USING gist (
      jurisdiction WITH =,
      daterange(valid_from, valid_to, '[)') WITH &&,
      tstzrange(tx_from, tx_to, '[)') WITH &&
  );

-- Индекс под типовой запрос «актуальное знание на дату действия»
CREATE INDEX vat_current_idx ON vat_rate_history (jurisdiction, valid_from, valid_to)
    WHERE tx_to IS NULL;

Честная цена битемпоральности:

Что Влияние
Объём данных Растёт на число исправлений: обычно ×1.2–2, в проблемных доменах хуже
Сложность запросов Каждый SELECT обрастает четырьмя условиями; без вью и хелперов ошибки неизбежны
Производительность Составные и частичные индексы обязательны; EXCLUDE на gist дороже уникального ключа при записи
Порог входа Новый разработчик почти наверняка напишет запрос без фильтра по времени знания и получит дубли
Отладка Зато инцидент «почему в отчёте другая цифра» перестаёт быть детективом на два дня

Практические смягчения: заведите вью vat_rate_current с уже наложенным tx_to IS NULL — пусть 90 % кода работает с ней; держите в документах снимок применённого значения (vat_rate_snapshot), он отвечает на большинство вопросов без обращения к истории; выносите аудит в отдельную таблицу, если он нужен только регулятору.


6. Календарь домена, часовые пояса и границы суток

Три ловушки, каждая из которых даёт баги, воспроизводимые раз в год.

«День» в домене не равен 24 часам UTC. Расчётный период оператора связи, банковский операционный день, торговая сессия, смена на складе — у каждого своё начало и своя зона. Правило: моменты хранить в UTC (timestamptz), а доменную зону держать рядом отдельным полем и вычислять границы суток через неё. «Прибавить три часа» в коде — гарантированная ошибка после очередной правки закона о времени.

Дата и момент — разные типы. Дата рождения, дата действия тарифа, отчётная дата — это date, без времени и без зоны. Момент события — datetime с зоной. Смешение даёт сдвиги на день у пользователей на востоке страны; в моделировании типами это разные типы, и это не педантизм.

Производственный календарь — часть домена, а не утилита. «Оплатить в течение 5 рабочих дней» зависит от юрисдикции, года и переносов, публикуемых постановлением. Календарь — данные с эффективными датами, версионируемые по тем же правилам, что и тарифы.

class BusinessCalendar(Protocol):
    """Порт домена: домен спрашивает «какой день считать рабочим»,
    но не знает, откуда берётся ответ."""

    def is_business_day(self, day: date) -> bool: ...
    def add_business_days(self, start: date, days: int) -> date: ...


def payment_deadline(invoice_issued_on: date, calendar: BusinessCalendar) -> date:
    """Правило домена читается вслух: «пять рабочих дней с даты выставления»."""
    return calendar.add_business_days(invoice_issued_on, 5)

Про физику времени в распределённых системах — часы, дрейф, монотонность, порядок событий — в отдельной статье соседнего трека. Здесь важно другое разделение: системное время нужно для порядка и отладки, доменное — для расчётов, и путать их нельзя даже когда они численно совпадают.


7. Долгоживущие процессы: дедлайны как часть модели

Последний аспект времени — процессы, которые длятся дольше запроса. «Подписка сгорает через 7 дней просрочки», «котировка действительна 30 дней», «претензию можно подать в течение 6 месяцев», «резерв держится 15 минут».

Соблазн — сделать крон, который раз в сутки ходит по таблице и меняет статусы. Это работает и это плохо по трём причинам: правило про 7 дней оказывается в скрипте, а не в домене; состояние объекта зависит от того, отработала ли джоба; воспроизвести поведение в тесте нельзя.

Первый вариант — недооценённый и в большинстве случаев правильный: состояние вычисляется, а не хранится.

@dataclass(frozen=True)
class Quotation:
    issued_on: date
    validity_days: int = 30

    def expires_on(self) -> date:
        return self.issued_on + timedelta(days=self.validity_days)

    def is_valid_on(self, moment: date) -> bool:
        """Никакой джобы: истечение — свойство времени, а не запись в БД."""
        return moment < self.expires_on()

Хранить статус «истекла» нужно только тогда, когда истечение порождает побочный эффект (письмо, списание, снятие резерва) или когда по этому статусу строят индексируемые выборки. Тогда это уже полноценный долгоживущий процесс — сага с таймаутом из статьи про события или отложенное сообщение; общая механика распределённых процессов разобрана в архитектурных паттернах.

И в любом случае: Clock как порт плюс тест на границу. Тесты «за день до», «в день», «на следующий день» стоят десять минут и ловят ошибки на единицу, которые иначе всплывают в бухгалтерии в конце квартала.


8. Типичные ошибки

  1. UPDATE на справочнике правил. Классика. Лечится эффективными датами: правило не меняется, у него появляется новая версия.
  2. Одна дата на все случаи. created_at используется и как дата документа, и как дата применения правила. Расхождения обнаруживаются в отчётности через квартал.
  3. now() внутри домена. Делает правила непроверяемыми и запрещает перерасчёты за прошлый период.
  4. Включающие границы периодов. [start, end] вместо [start, end) даёт вечные ошибки на один день и перекрытия на стыках. Полуинтервал — единственный вариант, у которого стыковка тривиальна.
  5. Битемпоральность «на всякий случай». Полная схема там, где хватило бы снимка значения в документе, — самый дорогой способ усложнить жизнь без пользы.
  6. Исправление прошлого вместо корректировки. Уничтожает воспроизводимость отчётов и аудит.
  7. Календарь как утилита. is_weekend(day) в utils.py вместо версионируемого производственного календаря: раз в год приходит постановление о переносе, и все расчёты сдвигаются.
  8. Крон как носитель бизнес-правила. Срок жизни котировки живёт в расписании джобы, а не в модели; при остановке джобы домен ведёт себя иначе.
  9. Хранение локального времени без зоны. timestamp without time zone в базе плюс «мы всегда в Москве» — работает ровно до первого клиента из другого часового пояса.

9. Мини-итог

  • В доменах со временем ответ зависит от двух независимых осей: когда факт верен для бизнеса (время действия) и когда мы о нём узнали (время знания).
  • Базовый приём — эффективные даты: правило хранится как последовательность версий с полуинтервалами [start, end), инвариант «без дыр и перекрытий» проверяется в конструкторе и, при конкурентной записи, в базе через EXCLUDE.
  • Каждое доменное вычисление параметризовано датой применения, и выбор этой даты — вопрос к эксперту, а не к разработчику. now() внутри домена запрещён.
  • Прошлое неизменяемо: изменение задним числом оформляется корректировкой — самостоятельным документом с причиной из словаря домена, а не UPDATE.
  • Битемпоральность вводится только при регуляторном или расчётном требовании воспроизвести прошлое знание; в остальных случаях достаточно снимка значения в документе.
  • Дата и момент — разные типы; доменный календарь и границы суток — часть модели, а не утилита.
  • Сроки и дедлайны по возможности вычисляются, а не хранятся; хранимый статус нужен, только когда истечение порождает побочный эффект.

Источники


Что дальше

Мы прошли весь путь: границы, тактика, события, углубление, дистилляция, типы, время. Остался вопрос, с которого в реальности всё и начинается: у вас уже есть система, ей семь лет, в ней полмиллиона строк, и переписать её нельзя. Как внедрять всё вышеописанное в код, который нельзя остановить, — и с чего начать в понедельник.

DDD в legacy: как внедрить модель в живую систему

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов