Хранение событий (Event Sourcing)
Вместо текущего состояния заказа хранится журнал всего, что с ним случилось, а состояние каждый раз получается проигрыванием этого журнала.
Проблема
Обычно заказ хранится строкой, которую каждая операция перезаписывает:
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 событий, а загрузка берёт снимок и дочитывает только хвост журнала после него. Снимок - кэш: его можно выбросить и пересчитать из журнала.
Структура
Правила заказа: позиции и отмена возможны только у оформленного заказа, оплата - только ровно на сумму позиций. Отказ - исключение до записи события, поэтому отклонённая команда не оставляет следа в журнале. В снимке нет списка позиций: правилам хватает итога и статуса, а полную историю всегда можно прочитать из журнала.
Как это собирается
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) - интерфейс загрузки и сохранения агрегата, здесь поверх журнала и снимков