События как источник истины
Event sourcing хранит состояние как append-only журнал неизменяемых фактов. Текущее состояние не хранится напрямую — оно вычисляется свёрткой (fold) журнала событий. События фиксируют, что произошло и почему, а не только итоговый снимок.
Биллинговая команда получила звонок от финансового директора в 21:00: «Скажите точно, в каком состоянии был заказ №48291 третьего марта в 14:00 и почему он изменился с “ожидает” на “одобрен” тем днём». Команда открыла базу данных. В таблице orders была одна строка для заказа №48291 с текущим статусом: «выставлен счёт». История исчезла. Таблица хранила только последнее состояние. Никакой записи о том, когда заказ был одобрен, кто это сделал, какими были позиции в момент одобрения и почему статус изменился, — не было. Историю восстанавливали по логам приложения — ненадёжным, неполным, не синхронизированным с деплоями. Чтобы дать частичный ответ, ушло шесть часов. Финансовый директор остался недоволен. Эта команда построила абсолютно нормальную CRUD-систему. Каждый UPDATE orders SET status = 'approved' уничтожал предыдущее состояние. «Почему» — бизнес-событие — нигде в схеме не было. Им нужен был не лучший запрос и не лучший индекс. Им нужна была другая модель того, что хранить в принципе.
CRUD-модель уничтожает историю
Обычная CRUD-система хранит текущее состояние. Когда заказ одобряется, приложение выполняет UPDATE orders SET status = 'approved' WHERE id = 48291. Предыдущее состояние — «ожидает» — исчезло. В базе данных нет никакой записи о переходе: ни когда он произошёл, ни что его вызвало, ни каким был payload в момент принятия решения.
Это не баг приложения. Это следствие модели данных. Реляционные таблицы спроектированы для хранения последнего снимка сущности. Такая модель эффективна, привычна и уместна для большого класса задач. Но у неё есть структурное следствие: история того, как сущность пришла к текущему состоянию, по умолчанию отбрасывается.
Команда, восстанавливавшая историю для финансового директора, делала вручную то, что должна была автоматически сохранить модель данных. Они обратно инженерили историю из логов, временных меток коммитов и памяти.
Модель event sourcing: только добавление, никогда перезапись
Event sourcing инвертирует это. Вместо хранения текущего состояния заказа вы храните каждое бизнес-событие, которое когда-либо произошло с этим заказом — в append-only журнале. Строки в таблице событий для заказа №48291 выглядят так:
seq event_type payload occurred_at
1 OrderPlaced {customer_id, line_items} 2024-03-01 09:12
2 OrderReviewStarted {reviewer_id} 2024-03-02 14:33
3 OrderApproved {approved_by, reason} 2024-03-03 14:07
4 InvoiceGenerated {invoice_id, amount} 2024-03-04 11:55Эти строки никогда не обновляются, никогда не удаляются. Событие, которое произошло, — это факт в прошедшем времени. OrderPlaced — это не описание текущего состояния, это запись о том, что размещение произошло. Event store — структурное выражение этой неизменяемости.
Текущее состояние — это left-fold журнала событий
Если журнал событий — источник истины, как получить текущее состояние заказа? Нужно свернуть (reduce) события слева направо, применяя каждое событие к аккумулятору, начинающемуся с пустого состояния:
currentState = events.reduce(applyEvent, initialState)applyEvent — чистая функция: принимает текущее накопленное состояние и событие, возвращает следующее состояние. Никаких побочных эффектов. Для каждого типа события она знает, как продвинуть состояние:
applyEvent(state, OrderPlaced) → state со status: "pending", line_items: [...]
applyEvent(state, OrderReviewStarted) → state со status: "under_review", reviewer_id: ...
applyEvent(state, OrderApproved) → state со status: "approved", approved_by: ...
applyEvent(state, InvoiceGenerated) → state со status: "invoiced", invoice_id: ...Результат свёртки — текущее состояние агрегата. Оно производно, а не хранится. Когда приложению нужно обработать команду для заказа №48291, оно загружает журнал событий этого заказа и сворачивает его, восстанавливая текущее состояние. Состояние никогда не записывается в нормализованный столбец.
▸lesson.inset.note
Это структурная связь между event sourcing и CQRS (юнит 07). Event sourcing касается write-стороны — как хранится состояние агрегата. CQRS — отдельный паттерн, разделяющий read и write модели. Они хорошо компонуются, но независимы: можно использовать event sourcing без CQRS и CQRS без event sourcing. Урок по проекциям CQRS (юнит 07) упоминал replay из журнала событий — это работа event sourcing.
События фиксируют намерение, а не только итоговый снимок
Критическое свойство хорошо названных событий: они фиксируют что произошло на уровне бизнеса, а не только результирующее изменение данных. OrderApproved — это не то же самое, что «столбец status изменился с under_review на approved». Название несёт намерение: было принято бизнес-решение одобрить заказ. Payload несёт контекст: кто одобрил, код причины, действующий порог одобрения на тот момент.
Это различие важно, когда доменная логика сложна. Рассмотрим изменение количества в позиции:
- CRUD:
UPDATE order_items SET quantity = 5 WHERE id = 77— только итоговый снимок - Event sourcing:
LineItemQuantityAdjusted { item_id: 77, previous_qty: 3, new_qty: 5, adjusted_by: "ops-team", reason: "supplier_substitution" }— намерение + контекст
Через шесть месяцев, когда возникнет спор о том, почему изменилось количество, event-sourced система ответит точно. CRUD-система предложит только цифру 5.
Команда обсуждает, применять ли event sourcing для агрегата заказов биллинговой платформы. Разработчик аргументирует: «Историю аудита можно получить, включив отслеживание изменений на уровне базы данных (CDC) или ведя отдельную таблицу аудита. Нам не нужно менять всю модель данных». В чём структурное различие между CDC/audit-log подходами и настоящим event sourcing?
Суперсила аудита и временны́х запросов
Структурное следствие хранения журнала событий как источника истины: вопрос финансового директора — «в каком состоянии был заказ №48291 третьего марта в 14:00?» — становится тривиальным:
events.filter(e => e.occurred_at <= "2024-03-03T14:00").reduce(applyEvent, initialState)Отфильтровать журнал событий до целевой временно́й метки, затем свернуть. Результат — точное состояние в тот момент. Никакой реконструкции из логов, никаких частичных ответов. Это временно́й запрос (temporal query): получение состояния сущности в любой момент её прошлого.
Временны́е запросы — не болт-он фича в event-sourced системах. Это структурное следствие модели данных. Журнал событий сохраняет каждый переход состояния; свёртка его префикса даёт любое прошлое состояние. Это делает event sourcing естественным выбором для доменов, где аудит и временны́е запросы — требования первого класса: финансовые системы, регулируемые домены, логистика, медицинские записи.
Команда проектирует агрегат заказов для event-sourced системы. Разработчик предлагает назвать событие 'OrderStatusUpdated' с payload { new_status: 'approved' }. Старший инженер возражает. В чём возражение и почему оно важно?
Чего event sourcing не является: замена CRUD повсюду
Event sourcing влечёт структурные издержки. Журнал событий для активно мутирующего агрегата накапливается бесконечно. Загрузка агрегата с тысячами событий требует их свёртки — производительность деградирует без митигации (снапшоты, рассматриваются в уроке 2 юнита 08). Запросы к текущему состоянию множества агрегатов одновременно требуют read model (проекции), построенной из журнала событий, — то есть запуска и поддержки pipeline проекций. Write-сторона и read-сторона теперь отдельные системы.
Это не теоретические издержки. Команды, применяющие event sourcing ко всем сущностям в системе — включая те, у которых нет требований к аудиту или временны́м запросам, — накапливают эти издержки без выгоды. Event sourcing подходит для агрегатов, где:
- История переходов состояния — бизнес-требование первого класса (аудит, комплаенс, урегулирование споров)
- Нужны временны́е запросы («на дату X»)
- Намерение за каждым изменением состояния нужно сохранять как структурированные данные
- Доменная логика достаточно сложна, чтобы журнал событий служил полным, инспектируемым протоколом бизнес-решений
Для сущности UserPreference, хранящей состояние переключателя UI, event sourcing — почти наверняка оверкилл. Знание того, что пользователь двадцать раз переключил тёмную тему, не является бизнес-требованием.
Биллинговая команда загружает агрегат заказа для обработки команды. У заказа 4200 событий в журнале. Текущая реализация сворачивает все 4200 событий при каждой команде. В чём структурная проблема и каково стандартное её решение в event sourcing?
- 01Что значит, что текущее состояние — это 'left-fold журнала событий'?
- 02Почему события в event sourcing используют названия в прошедшем времени (OrderApproved), а не в повелительном (ApproveOrder)?
- 03В чём ключевое структурное различие между event sourcing и добавлением таблицы аудита к CRUD-системе?
Звонок финансового директора в 21:00 обнажил структурную цену CRUD-модели: каждый UPDATE уничтожал предыдущее состояние. История того, как заказ пришёл к текущему статусу, исчезла. Её реконструкция из логов заняла шесть часов и дала частичный ответ.
Event sourcing решает это структурно, а не как запоздалую доработку. Журнал событий — append-only последовательность неизменяемых бизнес-фактов в прошедшем времени — является источником истины. Текущее состояние вычисляется свёрткой журнала, а не хранится напрямую. Свёртка детерминирована и воспроизводима. Временны́е запросы бесплатны: отфильтруй журнал до нужной временно́й метки и сверни префикс.
Хорошо названные события несут намерение. OrderApproved с одобряющим и кодом причины — это не то же самое, что изменение столбца status. Это разница между мутацией данных и бизнес-фактом. Именно это делает журнал событий полным, проверяемым протоколом бизнес-решений, а не просто историей записей строк.
Структурная цена реальна: производительность свёртки деградирует с длинными журналами (решается снапшотами); read-запросы требуют отдельного слоя проекций (рассматривается в уроке 2); эволюция схемы вводит задачи версионирования (рассматриваются в уроке 3). Event sourcing — не правильная модель для каждого агрегата. Это правильная модель, когда история бизнес-решений — требование первого класса.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.