handbookНаписать

architecture/event-sourcing/README.md · тема · 4 языка · 31 тест

Хранение событий (Event Sourcing) ​

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

Проблема ​

Обычно заказ хранится строкой, которую каждая операция перезаписывает:

php
final class OrderService
{
    public function pay(string $orderId, int $amount): void
    {
        $order = $this->db->find('orders', $orderId);       // status, total
        $order['status'] = 'paid';
        $this->db->update('orders', $order);                // previous state is gone
        $this->db->insert('order_log', ['order' => $orderId, 'text' => 'paid']); // if someone remembers
    }

    public function addGift(string $orderId, string $sku): void
    {
        $order = $this->db->find('orders', $orderId);       // read before pay() committed
        $order['lines'][] = ['sku' => $sku, 'price' => 0];
        $this->db->update('orders', $order);                // overwrites status back to "placed"
    }
}

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

Решение ​

Источник правды - журнал событий заказа: OrderPlaced, LineAdded, OrderPaid, OrderCancelled. Журнал только дополняется, события не меняются и не удаляются.

  • Команда на агрегате (addLine, pay, cancel) проверяет правила по текущему состоянию и, если всё в порядке, записывает новое событие. Само состояние меняет только одна функция apply (в Python и TypeScript - чистая evolve(state, event)), поэтому команда и проигрывание журнала дают одинаковый результат.
  • Восстановление - создать пустой заказ и применить все события по порядку (restore). Версия агрегата - число применённых событий.
  • Оптимистичная блокировка - при сохранении агрегат передаёт версию, с которой его загрузили. EventStore.append дописывает события, только если поток всё ещё на этой версии, иначе бросает ConcurrencyConflict. Блокировок на чтение нет, проигравший просто перечитывает заказ и решает заново.
  • Снимок (Snapshot) - сохранённое состояние на какой-то версии. Репозиторий делает его каждые N событий, а загрузка берёт снимок и дочитывает только хвост журнала после него. Снимок - кэш: его можно выбросить и пересчитать из журнала.

Структура ​

records, applies

every N events

save, restore

«interface»

OrderEvent

OrderPlaced

+orderId

+customerId

LineAdded

+sku

+quantity

+unitPriceKopecks

OrderPaid

+amountKopecks

OrderCancelled

+reason

Order

-status

-totalKopecks

-version

+place(id, customerId) : Order

+restore(history, snapshot) : Order

+addLine(sku, quantity, price)

+pay(amount)

+cancel(reason)

+releasePending() : list

-apply(event)

InMemoryEventStore

+append(streamId, expectedVersion, events)

+load(streamId, afterVersion) : list

OrderRepository

-snapshotEvery

+save(Order)

+load(id) : Order

Snapshot

+status

+totalKopecks

+version

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

Как это собирается ​

Composition root создаёт InMemoryEventStore и OrderRepository с N = 3. Обработчик оформления вызывает Order::place, добавляет две позиции и сохраняет: три события уходят в поток o-1, репозиторий делает снимок версии 3. Дальше два запроса загружают заказ одновременно: оплата и сотрудник поддержки, который хочет докинуть кружку в подарок. Оплата сохраняется первой и двигает поток на версию 4. Сохранение поддержки ждёт версию 3 и получает ConcurrencyConflict; обработчик перечитывает заказ, повторяет команду, и теперь уже агрегат отказывает: заказ оплачен. В конце журнал показывает четыре события, а загрузка берёт снимок v3 и проигрывает одно событие.

Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.

Реализации ​

Хранилище в памяти: словарь поток -> список событий, номер версии - длина списка. В проекте это таблица (stream_id, version, type, payload) с уникальным ключом (stream_id, version): база сама отклонит вторую вставку той же версии, и проверка expectedVersion превращается в обработку нарушения ключа.

ЯзыкОсобенность
PHPСобытия - final readonly class с маркерным интерфейсом OrderEvent; apply разбирает событие match (true) с instanceof; конфликт - ConcurrencyConflict extends RuntimeException
GoЗакрытый интерфейс Event с методом isEvent, switch e.(type) в apply; State встроен в Order, и снимок - просто копия o.State; ошибки - сентинелы ErrConcurrency, ErrRefused
PythonСостояние - frozen dataclass State, чистая функция evolve возвращает новое; match по позиционным шаблонам событий; Order.restore - это reduce в цикле
TypeScriptСобытия - размеченное объединение, evolve с switch (event.type) проверяется компилятором на полноту; history.reduce(evolve, snapshot) - восстановление одной строкой

Запуск: make test T=architecture/event-sourcing

Плюсы и минусы ​

Тесты одинаковы во всех языках: заказ, восстановленный из своих событий, совпадает с исходным; запись с устаревшей версией отклоняется и ничего не дописывает; после снимка на версии 3 загрузка читает одно событие хвоста и даёт то же состояние, что полное проигрывание; отклонённая команда (позиция или отмена после оплаты, позиция после отмены, неверная сумма) не двигает версию.

  • ✅ Полная история бесплатно: аудит, разбор инцидентов, ответ на "почему заказ такой" - это чтение журнала.
  • ✅ Новые проекции и отчёты строятся по старым событиям задним числом, как будто их считали с первого дня.
  • ✅ Конкурентные изменения не теряются: версия потока ловит гонку, которую UPDATE молча проглатывает.
  • ✅ Запись - только вставка в конец, без обновлений и блокировок строк.
  • ❌ Состояние нельзя просто выбрать запросом: для поиска и списков нужны проекции (обычно вместе с CQRS) и их отставание.
  • ❌ События вечны. Переименовать поле или поменять смысл события нельзя, нужны версии событий и апкастеры, которые читают старые форматы.
  • ❌ Исправить ошибочные данные UPDATE-ом нельзя, только компенсирующим событием. Удаление персональных данных по закону требует отдельных приёмов (шифрование с удаляемым ключом).
  • ❌ Длинные потоки грузятся медленно, нужны снимки, а снимки - это ещё один формат, который надо версионировать вместе с агрегатом.

Когда не применять ​

  • Большинству частей системы история не нужна: справочники, настройки, профиль пользователя. Event Sourcing выбирают для отдельного агрегата, где история и есть ценность (деньги, заказы, документооборот), а не для всего приложения.
  • Нужен только аудит. Таблица истории или CDC из лога базы дают "кто и когда поменял" без перестройки всей модели хранения.
  • Команда не готова жить с версионированием событий, проекциями и перестройкой read-моделей. Ошибка в схеме события через год стоит дороже, чем ошибка в схеме таблицы: таблицу можно мигрировать, а журнал нет.
  • Предметная область ещё не устоялась и события будут часто меняться по смыслу. Сначала имеет смысл понять домен на обычной модели, а события вводить, когда их язык стабилен.
  • Защита от гонок нужна, а история нет: оптимистичную блокировку даёт и колонка version в обычной таблице (UPDATE ... WHERE version = ?).

Отличия между языками ​

  • PHP - match (true) с instanceof - самый короткий разбор событий без отдельного метода на каждое (applyOrderPlaced), как делают EventSauce и Prooph. Защита полноты держится на ветке default с исключением: PHP не проверит при компиляции, что новое событие обработано.
  • Go - встраивание State в Order даёт и доступ к полям (o.Status), и снимок одной копией o.State; структуры сравниваются !=, поэтому тест сравнивает состояния целиком. Конфликт - не исключение, а error, и вызывающий различает его через errors.Is(err, ErrConcurrency).
  • Python - состояние неизменяемое, evolve возвращает новый State через replace, поэтому снимок можно хранить без копирования: его никто не поменяет. match с позиционными шаблонами разбирает dataclass без isinstance.
  • TypeScript - evolve без ветки default с объединением OrderEvent компилятор проверяет на полноту: добавили событие и не обработали - функция перестанет возвращать State во всех ветках. Object.freeze защищает записанные события в рантайме.

Связанные темы ​

  • Тактический DDD - тот же агрегат заказа, но состояние хранится, а не выводится из событий
  • CQRS - проекции из событий для чтения; без них по журналу нельзя искать
  • Доменное событие (Domain Event) - события как факты; здесь они становятся ещё и хранилищем
  • Outbox - публикация событий наружу; в Event Sourcing журнал сам служит outbox
  • Репозиторий (Repository) - интерфейс загрузки и сохранения агрегата, здесь поверх журнала и снимков
© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS