Время в домене: процессы, версии правил и темпоральные данные
Есть класс доменов, где аккуратная модель из тактических блоков и красивые типы всё равно рассыпаются, и всегда по одной причине. Страхование, биллинг, тарифы, зарплата, налоги, договоры, кадровый учёт, медицина, логистика: в каждом из них правильный ответ на вопрос бизнеса зависит не от текущего состояния, а от того, на какой момент спрашивают и что мы знали на этот момент.
Классический разрушительный код выглядит безобидно:
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 лет — сторнирующая проводка вместо удаления ошибочной.
старая закрыта по времени знания Note over SCH: ничего не удалено:
оба факта доступны навсегда SCH-->>REC: событие VatRateCorrected REC->>LED: перечитать начисления за период по новой ставке LED->>LED: вычислить дельту по каждому документу alt дельта не нулевая LED->>LED: создать КОРРЕКТИРУЮЩЕЕ начисление
со ссылкой на исходный документ LED-->>CLI: уведомление о перерасчёте и основание else дельта нулевая LED-->>REC: документы не затронуты end
В коде это выражается тем, что корректировка становится отдельным понятием домена — со своим именем, своим документом и своими правилами, а не техническим апдейтом:
@dataclass(frozen=True)
class Adjustment:
"""Корректировка — самостоятельный факт, а не правка старого.
У неё своя дата действия (период, за который пересчитали)
и своя дата документа (когда мы это сделали)."""
id: AdjustmentId
corrects: DocumentId # ссылка на исходный документ, не замена ему
period: EffectivePeriod # за какой период пересчёт
delta: Money # знак важен: и доначисление, и возврат
reason: AdjustmentReason # понятие домена: ошибка ставки, ретро-скидка, суд
issued_on: date # когда оформили корректировку
Три правила, которые стоит принять как аксиомы:
- Никогда не меняйте выпущенный документ. Счёт, полис, накладная, начисление — выпущенные, они неизменяемы. Есть только корректирующие документы.
- У корректировки есть причина из словаря домена.
reason: strсо свободным текстом через год превращается в свалку; эксперт всегда может перечислить конечный список оснований. - Перерасчёт — процесс, а не функция. Он затрагивает много документов, идёт долго, может быть частично неуспешным и требует отчёта. Оформляйте его как долгоживущий процесс (см. раздел 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 дней оказывается в скрипте, а не в домене; состояние объекта зависит от того, отработала ли джоба; воспроизвести поведение в тесте нельзя.
«через N дней происходит X»"] --> B{"Нужна ли реакция
ровно в момент срока?"} B -- нет --> C["Вычисляемое состояние:
is_expired(now) — чистая функция.
Хранить нечего, джоба не нужна"] B -- да, нужны письмо
или списание --> D{"Точность важнее минут?"} D -- нет --> E["Периодическая выборка по индексу:
WHERE deadline < now AND state = ...
идемпотентный обработчик"] D -- да --> F["Отложенное сообщение:
таймер брокера, delayed queue,
сага с таймаутом"] C --> G["Правило живёт в домене
как функция от времени"] E --> G F --> G G --> H["Тест: подменяем Clock
и проверяем поведение на любую дату"]
Первый вариант — недооценённый и в большинстве случаев правильный: состояние вычисляется, а не хранится.
@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. Типичные ошибки
UPDATEна справочнике правил. Классика. Лечится эффективными датами: правило не меняется, у него появляется новая версия.- Одна дата на все случаи.
created_atиспользуется и как дата документа, и как дата применения правила. Расхождения обнаруживаются в отчётности через квартал. now()внутри домена. Делает правила непроверяемыми и запрещает перерасчёты за прошлый период.- Включающие границы периодов.
[start, end]вместо[start, end)даёт вечные ошибки на один день и перекрытия на стыках. Полуинтервал — единственный вариант, у которого стыковка тривиальна. - Битемпоральность «на всякий случай». Полная схема там, где хватило бы снимка значения в документе, — самый дорогой способ усложнить жизнь без пользы.
- Исправление прошлого вместо корректировки. Уничтожает воспроизводимость отчётов и аудит.
- Календарь как утилита.
is_weekend(day)вutils.pyвместо версионируемого производственного календаря: раз в год приходит постановление о переносе, и все расчёты сдвигаются. - Крон как носитель бизнес-правила. Срок жизни котировки живёт в расписании джобы, а не в модели; при остановке джобы домен ведёт себя иначе.
- Хранение локального времени без зоны.
timestamp without time zoneв базе плюс «мы всегда в Москве» — работает ровно до первого клиента из другого часового пояса.
9. Мини-итог
- В доменах со временем ответ зависит от двух независимых осей: когда факт верен для бизнеса (время действия) и когда мы о нём узнали (время знания).
- Базовый приём — эффективные даты: правило хранится как последовательность версий с
полуинтервалами
[start, end), инвариант «без дыр и перекрытий» проверяется в конструкторе и, при конкурентной записи, в базе черезEXCLUDE. - Каждое доменное вычисление параметризовано датой применения, и выбор этой даты — вопрос
к эксперту, а не к разработчику.
now()внутри домена запрещён. - Прошлое неизменяемо: изменение задним числом оформляется корректировкой — самостоятельным
документом с причиной из словаря домена, а не
UPDATE. - Битемпоральность вводится только при регуляторном или расчётном требовании воспроизвести прошлое знание; в остальных случаях достаточно снимка значения в документе.
- Дата и момент — разные типы; доменный календарь и границы суток — часть модели, а не утилита.
- Сроки и дедлайны по возможности вычисляются, а не хранятся; хранимый статус нужен, только когда истечение порождает побочный эффект.
Источники
- Martin Fowler. Temporal Patterns — Effectivity, Temporal Property, Temporal Object, Audit Log; плюс Bitemporal History — лучшее краткое введение в две оси времени.
- Richard Snodgrass. Developing Time-Oriented Database Applications in SQL — свободно доступная классическая монография по темпоральным БД; обзор темпоральных возможностей SQL:2011 — здесь.
- PostgreSQL: range types и exclusion constraints.
- Vaughn Vernon. Implementing Domain-Driven Design, гл. 6 — даты и периоды как объекты-значения.
- Соседние треки: моделирование данных, транзакции и изоляция, время и часы в распределённых системах.
Что дальше
Мы прошли весь путь: границы, тактика, события, углубление, дистилляция, типы, время. Остался вопрос, с которого в реальности всё и начинается: у вас уже есть система, ей семь лет, в ней полмиллиона строк, и переписать её нельзя. Как внедрять всё вышеописанное в код, который нельзя остановить, — и с чего начать в понедельник.