Event-driven Architecture
Предпосылки: Профили нагрузки (индексы замедляют запись, read-heavy vs write-heavy), Модели консистентности (eventual consistency, read-your-writes), Паттерны надёжности (idempotency), Message Queues (temporal decoupling, pub/sub, log-based vs queue-based брокер), Микросервисы (текущая архитектура магазина: Orders, события, подписчики).
← Микросервисы | Система бронирования отелей →
Архитектура из предыдущей заметки работает: Orders пишет в PostgreSQL, checkout координируется через saga, событие order.completed уходит в очередь, Notification, Loyalty и Analytics подписываются самостоятельно. Но Orders-сервис обслуживает не только checkout. Бизнес хочет панель продавца: список заказов с фильтрацией по статусу, дате, городу, полнотекстовый поиск по товарам, выручка за период, топ продаж. Все эти данные лежат в той же PostgreSQL, куда пишутся заказы. Одна модель данных — два конфликтующих паттерна доступа.
Конфликт паттернов доступа
Таблица orders оптимизирована под запись: нормализованная схема (3НФ), три индекса — по user_id (мои заказы), created_at (сортировка), status (фильтрация). Три индекса — терпимо для checkout, который делает INSERT, UPDATE в inventory и внешний вызов Payment.
Панель продавца требует другого. Фильтрация по продавцу и дате — индекс по (seller_id, created_at). Фильтрация по городу — JOIN с addresses, индекс по city. Топ товаров — JOIN orders + order_items + products, GROUP BY, ORDER BY count. Полнотекстовый поиск по названиям товаров — GIN-индекс на tsvector. Итого: было 3 индекса, стало 7. Каждый checkout INSERT теперь обновляет 7 индексов вместо 3. При 500 заказах в секунду разница ощутима — каждый дополнительный индекс это запись в ещё одну структуру данных на диске.
Но проблема глубже индексов. Аналитический запрос «выручка за месяц по дням» — sequential scan или index scan по сотням тысяч строк с агрегацией. Он держит соединение из пула секунды, а не миллисекунды. Пока десять продавцов смотрят дашборд одновременно — checkout начинает ждать свободного соединения.
Read replica? Частично помогает — аналитику можно отправить на реплику. Но схема на реплике та же, нормализованная. Тяжёлые JOIN с GROUP BY всё равно дорогие. И добавить индекс только на реплику нельзя — реплика воспроизводит WAL с primary, включая структуру индексов.
Корень проблемы: одна модель данных обслуживает два принципиально разных паттерна доступа. Запись хочет минимум индексов, нормализованную схему, быстрые точечные INSERT/UPDATE. Чтение хочет много индексов, денормализованные данные (pre-joined, pre-aggregated), сложные выборки.
Materialized view и триггеры
Первая идея — materialized view. Денормализованное представление с заранее подтянутыми JOIN-ами:
CREATE MATERIALIZED VIEW seller_orders_view AS
SELECT o.id, o.status, o.total, o.created_at,
p.name AS product_name, a.city
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
JOIN addresses a ON a.id = o.shipping_address_id
WHERE o.seller_id IS NOT NULL;
CREATE INDEX idx_seller_view_seller ON seller_orders_view(seller_id, created_at);
CREATE INDEX idx_seller_view_city ON seller_orders_view(city);Данные денормализованы, индексы свои — запись в основную таблицу orders не затрагивает эти индексы. Идея правильная: хранить данные для чтения в отдельной, заранее подготовленной форме.
Проблема в механизме обновления. REFRESH MATERIALIZED VIEW — полный пересчёт: PostgreSQL выполняет весь SELECT заново и перезаписывает таблицу. При 10 миллионах заказов — минуты. CONCURRENTLY не блокирует чтение, но сам рефреш по-прежнему тяжёлый. Рефреш каждые 5 минут означает, что продавец не видит свежий заказ 5 минут. Рефреш каждые 30 секунд — та же нагрузка на primary, от которой уходили, только теперь по расписанию. Нативного инкрементального обновления materialized views в PostgreSQL нет (по состоянию на PostgreSQL 17); расширение pg_ivm существует, но не в ядре.
Есть более простой механизм: триггер. AFTER INSERT ON orders вставляет денормализованную строку в отдельную read-таблицу seller_orders_read. Инкрементально, без full scan, без расширений. Триггер выполняется в той же транзакции, что и INSERT — не бывает ситуации «записали заказ, но не обновили read-таблицу». Данные согласованы транзакционно.
BEGIN (checkout transaction)
INSERT INTO orders (...)
UPDATE inventory SET reserved = reserved + 1 WHERE sku = ?
TRIGGER: INSERT INTO seller_orders_read (
SELECT o.id, o.total, p.name, a.city ...
FROM orders o JOIN ... WHERE order_id = NEW.id
)
COMMIT
Для одной read-модели внутри одного PostgreSQL этого достаточно. Это уже разделение моделей чтения и записи в минимальном виде — запись идёт в нормализованную orders, чтение продавца — из денормализованной seller_orders_read.
От одной read-модели к нескольким
Триггер решает проблему для панели продавца. Но бизнес растёт, и потребителей данных становится три.
Панель продавца — денормализованные заказы в PostgreSQL read-таблице. Поиск — продавец ищет заказ по имени покупателя, адресу, SKU товара; при миллионах заказов полнотекстовый поиск с ранжированием и фасетами выходит за пределы того, для чего PostgreSQL оптимизирован, — здесь нужен Elasticsearch. Аналитика — выручка по дням, воронка конверсии, когортный анализ — ClickHouse, который уже есть в архитектуре.
Триггер живёт внутри PostgreSQL-транзакции. Он не может надёжно записать в Elasticsearch или ClickHouse — это внешние системы, транзакция PostgreSQL их не покрывает. Если Elasticsearch недоступен в момент триггера — транзакция checkout откатывается. Покупатель не может оплатить заказ из-за того, что поисковый индекс для продавца лежит.
Нужно temporal decoupling — развязка записи заказа и обновления read-моделей по времени. Запись фиксирует факт и завершается; read-модели обновляются позже, асинхронно.
Background job через Sidekiq — первый вариант. after_commit callback в Rails ставит задачу: SyncOrderToElasticsearchJob.perform_async(order_id). Retry с backoff из коробки, обновление per-event (секунды, не минуты). Работает для трёх потребителей — три джоба на каждый заказ:
after_commit :enqueue_sync_jobs
def enqueue_sync_jobs
UpdateSellerReadTableJob.perform_async(id)
SyncToElasticsearchJob.perform_async(id)
SyncToClickHouseJob.perform_async(id)
endПроблема — coupling. Orders знает обо всех потребителях. Появился четвёртый consumer (рекомендательная система) — ещё один джоб. Пятый (fraud detection) — ещё один. Каждый новый потребитель данных требует изменения кода Orders-сервиса: добавить callback, добавить джоб. Orders отправляет команды («синхронизируй в Elasticsearch»), а не факты. Это зависимость от внешних модулей (efferent coupling).
Решение — sub. Orders публикует событие — факт о том, что произошло — в message queue: order.completed, order.shipped, order.cancelled. Потребители подписываются сами. Добавление нового consumer — ноль изменений в коде Orders.
БЫЛО (Sidekiq, Orders знает всех):
Orders ──> SyncElasticsearchJob
──> SyncClickHouseJob
──> UpdateSellerReadJob
──> каждый новый consumer = изменение Orders
СТАЛО (pub/sub, Orders знает только событие):
Orders ──> publish("order.completed", {order_id, seller_id, total, ...})
│
┌─────┼──────┬──────────┬──────────┐
▼ ▼ ▼ ▼ ▼
ReadDB ES ClickH Recommend Fraud
(подписались сами, Orders не знает о них)
Ключевое различие: команда подразумевает знание получателя и ожидаемого действия; событие — нет. Команда — «сделай вот это». Событие — «вот что произошло». В Sidekiq-варианте Orders отправлял команды (command vs event в Sidekiq). В pub/sub — публикует события.
CQRS
Отдельная модель для записи (нормализованная, мало индексов), отдельные модели для чтения (денормализованные, в подходящих хранилищах), синхронизация через события — всё, что мы построили за последние три шага, имеет название.
Command Query Responsibility Segregation — разделение ответственности между командами и запросами. Command — операция, которая меняет состояние (создать заказ, сменить статус). Query — операция, которая читает состояние (список заказов продавца, аналитика). Они обрабатываются разными моделями.
Термин ввёл Грег Янг (Greg Young) в ~2010, развив идею Бертрана Мейера — CQS (Command Query Separation). CQS — принцип уровня кода: метод либо возвращает результат и не меняет состояние, либо меняет состояние и ничего не возвращает. CQRS — архитектурный паттерн: разные модели данных для записи и чтения.
В нашем сценарии:
Write model (command side): нормализованная схема в PostgreSQL, оптимизированная под checkout. Минимум индексов. Транзакция: INSERT order → UPDATE inventory → вызов Payment. После commit — публикация события в message queue.
Read models (query side): отдельные хранилища, каждое оптимизировано под свой паттерн чтения.
COMMAND SIDE QUERY SIDE
┌─────────────────┐
│ Orders Service │
CreateOrder ──>│ │──> event: "order.completed"
ChangeStatus──>│ Normalized DB │ │
CancelOrder───>│ (PostgreSQL) │ ┌────┴──────────────────┐
│ 3 индекса │ │ Message Queue │
└─────────────────┘ └────┬────┬────┬────────┘
│ │ │
v v v
┌────────┐ ┌──┐ ┌───────────┐
│Seller │ │ES│ │ ClickHouse│
│ReadDB │ │ │ │ │
│(PG) │ │ │ │ │
└────────┘ └──┘ └───────────┘
список поиск аналитика
заказов
Каждый consumer на query side — проекция (projection). Проекция — процесс, который слушает поток событий и строит из них read-модель. Название по аналогии с геометрической проекцией: трёхмерный объект (полное состояние системы) проецируется на плоскость (конкретное представление для конкретного потребителя). Разные плоскости — разные проекции одних и тех же данных.
class SellerOrdersProjection
def handle(event)
case event.type
when "order.completed"
SellerOrderRead.create!(
order_id: event.data[:order_id],
seller_id: event.data[:seller_id],
product_name: event.data[:product_name],
city: event.data[:city],
total: event.data[:total],
status: "completed"
)
when "order.cancelled"
SellerOrderRead.where(order_id: event.data[:order_id])
.update!(status: "cancelled")
end
end
endСобытие несёт все данные, нужные проекции. Проекция не ходит в write-базу за дополнительной информацией — иначе снова coupling между read и write сторонами.
Цена: eventual consistency
Write-модель и read-модели синхронизируются через очередь. Между моментом создания заказа в write-базе и его появлением в seller read table проходит время — 100ms, секунда, при проблемах с очередью — минуты. Это eventual consistency.
Для панели продавца секундная задержка допустима. Для покупателя, который только что оплатил и хочет увидеть свой заказ, — нет. Поэтому покупатель читает из write-модели (его запрос простой: WHERE user_id = ? ORDER BY created_at DESC), а продавец — из read-модели. CQRS не означает «всё чтение идёт через read-модель». Это выбор, откуда читать, в зависимости от требований к consistency и паттерна запроса.
Когда CQRS не нужен
CQRS — не замена обычной архитектуре. Если чтение и запись работают с одними и теми же данными в одной форме (типичный CRUD), CQRS добавляет сложность без выгоды. Сигналы, что CQRS не нужен: одна команда разработчиков, один-два клиента с похожими паттернами чтения, нагрузка помещается в один PostgreSQL с индексами, нет аналитики или она решается простым SELECT ... GROUP BY.
Сигналы, что пора: read и write хотят разные индексы, разные формы данных (нормализованная vs денормализованная), разные хранилища (PostgreSQL vs Elasticsearch vs ClickHouse), или чтение нагружает write-базу до деградации основных операций.
CQRS не требует distributed log, Kafka или отдельных баз данных. В простейшей форме — триггер, заполняющий read-таблицу в той же PostgreSQL. Спектр решений: триггер (синхронный, в транзакции) → background job (асинхронный, coupling) → pub/sub (асинхронный, decoupled). Каждый шаг добавляет сложность и убирает ограничение.
Потеря истории в мутабельном состоянии
В текущей CQRS-архитектуре write-модель — обычная PostgreSQL с мутабельным состоянием. UPDATE orders SET status = 'shipped' WHERE id = 42 перезаписывает старое значение. Событие order.shipped публикуется и уходит в проекции, но write-база хранит только текущий снимок.
Менеджер спрашивает: «Заказ #42 — когда он перешёл из paid в shipped?» Ответить нельзя — в базе только status = 'shipped' и updated_at. Прагматичное решение: добавить paid_at, shipped_at, delivered_at — отдельный timestamp на каждый переход. Или JSONB-поле status_history:
[
{"status": "created", "at": "2025-06-01T12:00:01Z"},
{"status": "paid", "at": "2025-06-01T12:00:05Z"},
{"status": "shipped", "at": "2025-06-03T09:15:00Z"}
]Работает. Но через месяц менеджер спрашивает: «Заказ #42 — клиент говорит, сумма была 7000, а списали 8500. Что произошло?» В базе total = 8500, предыдущее значение перезаписано. Ещё через месяц: «Почему товар X оказался в заказе?» Таблица order_items мутабельна — INSERT/DELETE не оставляют следа.
Каждый раз, когда бизнес хочет знать «как мы пришли в текущее состояние», нужно ещё одно поле истории: status_history, total_history, items_history. По сути — ручная достройка лога изменений рядом с мутабельным состоянием.
Audit log vs domain events
Audit log решает часть этой проблемы. Logidze (расширение для Rails) автоматически записывает дифы через триггер и поддерживает мета-информацию: кто сделал изменение, из какого контекста, с какой целью. PaperTrail работает аналогично. Для большинства Rails-приложений audit log с метой — достаточно для ответа на вопрос «кто и когда это сделал».
Различие между audit log и domain events — в том, что каждый из них записывает. Audit log фиксирует диф данных: {changes: {total: [8500, 10500]}, at: "..."}. Что произошло — непонятно без сопоставления дифов разных полей и таблиц. Domain event фиксирует бизнес-факт: ItemAdded {sku: "B", price: 2000, qty: 1, added_by: "support_agent_5", reason: "customer_request", ticket: "SUP-1234"}. Причина изменения — в самом событии.
Вторая разница — в гарантиях. Audit log — побочный продукт записи. Если триггер Logidze не сработал (баг, прямой SQL в консоли, миграция данных), данные изменились, а лог нет. В event sourcing событие — единственный способ изменить состояние. Нет события — нет изменения.
Event Sourcing
Вместо хранения текущего состояния и дописывания истории — хранить только историю, а текущее состояние вычислять из неё.
Последовательность событий заказа #42:
Event 1: OrderCreated {user: 7, items: [{sku: "A", price: 7000, qty: 1}]}
Event 2: DeliveryChosen {method: "express", cost: 1500}
Event 3: PaymentCharged {amount: 8500, charge_id: "ch_abc"}
Event 4: ItemAdded {sku: "B", price: 2000, qty: 1, added_by: "support"}
Event 5: TotalRecalculated {old: 8500, new: 10500}
Event 6: OrderShipped {tracking: "SF123456"}
Текущее состояние: status = shipped, total = 10500, items = [A, B]. Но теперь можно ответить на любой вопрос: товар B добавлен саппортом (Event 4), total изменился из-за этого (Event 5), изначальная сумма 8500 включала экспресс-доставку (Event 2).
Источником истины становится не текущее состояние, а последовательность событий. Состояние — производная величина, вычисляемая применением событий одного за другим:
class Order
def apply(event)
case event.type
when "OrderCreated"
@status = :created
@items = event.data[:items]
@total = event.data[:total]
when "ItemAdded"
@items << event.data[:item]
@total += event.data[:item][:price]
when "PaymentCharged"
@status = :paid
when "OrderShipped"
@status = :shipped
@tracking = event.data[:tracking]
end
end
endSnapshot: оптимизация replay
Чтобы получить текущее состояние заказа, нужно воспроизвести (replay) всю цепочку событий с начала. Для заказа с 5–10 событиями — микросекунды. Для сущности с тысячами событий (банковский счёт за 10 лет) — проблема.
Snapshot (снимок) решает это: периодически сохраняется вычисленное состояние на определённый момент. Чтобы получить текущее состояние — загрузить последний snapshot и воспроизвести только события после него.
Events: [1] [2] [3] ... [500] [501] [502] [503]
↑
Snapshot: {balance: 45000, status: active, ...}
Чтение: загрузить snapshot + replay 501-503
вместо replay 1-503
Если snapshot обновляется каждые 10 событий, replay — максимум 10 событий. Каждое событие просто применяет функцию к объекту в памяти — наносекунды. Загрузка: один SELECT snapshot + один SELECT последних N событий по stream_id. Два запроса — сопоставимо с обычным SELECT * FROM orders WHERE id = 42.
Дорогой replay — восстановление проекции с нуля. Новый consumer хочет построить read-модель: нужно пройти все события всех заказов. Это одноразовая операция (или редкая — при исправлении бага в проекции), выполняется в фоне.
Event Sourcing + CQRS
Два паттерна естественно сочетаются. Event sourcing порождает поток событий, CQRS его потребляет.
WRITE SIDE READ SIDE
┌──────────┐ publish ┌───────────────────────┐
│ Command │─────────────────> │ Message Queue │
│ Handler │ └────┬────┬────┬────────┘
└────┬─────┘ │ │ │
│ v v v
v ┌──────┐ ┌──┐ ┌───────────┐
┌──────────┐ │Seller│ │ES│ │ ClickHouse│
│ Event │ │ReadDB│ │ │ │ │
│ Store │ └──────┘ └──┘ └───────────┘
│(append- │
│ only) │ Проекция Проекция Проекция
└──────────┘ списка поиска аналитики
заказов
Command приходит → command handler валидирует, загружает текущее состояние (snapshot + replay), применяет бизнес-логику, записывает новое событие в event store → событие публикуется → проекции обновляют read-модели.
Но CQRS и event sourcing — независимые паттерны. Можно CQRS без event sourcing: обычная PostgreSQL на write-стороне, проекции обновляются через события. Можно event sourcing без CQRS: все читают через replay, без отдельных read-моделей — работает, пока запросы ограничены одной сущностью (SELECT ... WHERE order_id = 42). Для запросов через множество сущностей (WHERE seller_id = ? AND city = ?) replay не поможет — нужны проекции.
Eventual consistency между write и read сторонами одинакова что с event sourcing, что без: задержка определяется очередью, а не источником данных на write-стороне.
Event store
Event sourcing записывает события — но куда? Нужно хранилище, оптимизированное под append-only запись с гарантией порядка внутри потока.
Append-only таблица в PostgreSQL — самый распространённый вариант для команд, у которых PostgreSQL уже есть:
CREATE TABLE events (
id BIGSERIAL PRIMARY KEY,
stream_id UUID NOT NULL,
version INTEGER NOT NULL,
event_type VARCHAR NOT NULL,
data JSONB NOT NULL,
metadata JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (stream_id, version)
);Constraint UNIQUE (stream_id, version) — ключевая гарантия. Это optimistic concurrency: два процесса одновременно читают заказ #42 на version 5, оба пытаются записать version 6. Один получает unique violation — и должен перечитать состояние и повторить. Защита от race condition без блокировок — тот же принцип, что optimistic locking в ActiveRecord (lock_version), но на уровне event stream.
Запись — INSERT. Чтение состояния — SELECT * FROM events WHERE stream_id = ? AND version > ? ORDER BY version. Snapshot — отдельная таблица.
Специализированные event store
EventStoreDB (open source) — база, спроектированная только под event sourcing. Три преимущества над PostgreSQL:
Подписки на события (catch-up subscriptions). Проекция подписывается: «дай все события с позиции N и далее в реальном времени». EventStoreDB поддерживает это нативно. В PostgreSQL нужен polling (SELECT ... WHERE id > last_seen) или LISTEN/NOTIFY. Polling добавляет задержку, NOTIFY не гарантирует доставку при переподключении.
Глобальный порядок. Все события всех потоков имеют глобальную позицию. Проекция, которая строит read-модель по событиям из разных потоков (заказы + платежи + доставка), читает единый упорядоченный поток. В PostgreSQL BIGSERIAL даёт примерный порядок, но при конкурентных транзакциях — не строгий (транзакция с id=100 может закоммититься после id=101).
Проекции как first-class citizen. Встроенный механизм для определения проекций прямо в базе. На практике проекции обычно живут в коде приложения.
Для большинства систем PostgreSQL как event store достаточен. EventStoreDB оправдан, когда event sourcing — центральный паттерн всей системы (финтех, банкинг, букмекерские платформы).
Подводные камни
Архитектура описана — но production добавляет проблемы, к которым нужно быть готовым.
Idempotency проекций
Проекция читает событие из очереди и обновляет read-модель. Очередь доставила событие дважды — стандартное поведение при at-least-once delivery. Для UPDATE status = 'shipped' повторное применение безвредно — идемпотентная операция. Но если проекция считает агрегат:
# Событие: OrderCompleted {seller_id: 7, total: 8500}
# Проекция:
SellerStats.increment(:revenue, event.data[:total])
# Дубль: revenue += 8500 ещё раз — выручка завышенаРешение — проекция хранит last_processed_event_id. Перед обработкой проверяет: событие уже обработано? Если да — пропускает. Тот же принцип idempotency, но на стороне consumer-а. В Kafka цепочку consume-transform-produce можно обернуть в транзакцию, атомарно записав результат и коммит offset’а — это exactly-once на уровне брокера без ручной дедупликации.
def handle(event)
return if already_processed?(event.id)
case event.type
when "order.completed"
SellerStats.increment(:revenue, event.data[:total])
end
mark_processed(event.id)
endOrdering между потоками
События одного потока (заказа) упорядочены: version 1, 2, 3. Но если проекция зависит от событий из разных потоков — порядок не гарантирован. UserRenamed {user_id: 7} может прийти раньше OrderCreated {user_id: 7} или позже. Проекция должна корректно обрабатывать события в любом порядке между потоками. На практике это означает: проекция обновляет данные по мере поступления, и eventual consistency приводит к корректному состоянию — UserRenamed обновит имя, когда придёт, независимо от порядка.
Сломанная проекция
Баг в коде проекции. Три дня неправильно считала revenue. Данные в read-модели испорчены. В обычной системе — писать миграцию, вычислять правильные значения, проверять вручную. В event sourcing — replay: исправить код проекции, очистить read-модель, прогнать все события с начала. Read-модель восстановлена корректно.
Цена: replay миллионов событий занимает время. На это время read-модель недоступна или показывает частичные данные. На практике часто делают параллельный rebuild: строят новую read-модель рядом со старой и переключают трафик, когда новая догнала поток событий.
Версионирование событий
Событие OrderCreated с полем amount опубликовано, consumer-ы от него зависят. Решили переименовать amount в total — старые события в логе записаны с amount. Нельзя просто мигрировать, как ALTER TABLE. Решение — upcasting: трансформер, который при чтении старого события приводит его к новой версии. Operational overhead, который нужно учитывать при проектировании событий как контрактов.
Стоимость event sourcing поверх CQRS
Event sourcing добавляет стоимость не в виде eventual consistency (она определяется очередью, одинакова что с ES, что без), а в виде: версионирования событий (upcasting при изменении контракта), snapshot-ов (когда создавать, как хранить, когда инвалидировать), размера хранилища (10-20 событий на сущность vs одна мутабельная строка).
Взамен даёт возможность, которой CQRS без ES не имеет: replay. Сломалась проекция — пересобрал с нуля. Появился новый consumer через полгода — прогнал все исторические события. В CQRS без ES для этого нужен отдельный ETL (Extract-Transform-Load) — ручной пайплайн экспорта данных.
Когда что выбирать
PG + replica CQRS CQRS + ES
────────────────────────────────────────────────────────────────────────
Write-модель мутабельная мутабельная append-only
(UPDATE) (UPDATE) (events)
Read-модель та же схема отдельные отдельные
(на реплике) проекции проекции
Consistency replication lag eventual eventual
(read vs write) (очередь) (очередь)
OLTP write обычный обычный append + snapshot
OLTP read обычный из read-модели snapshot + replay
или write-модели или read-модели
История нет (или audit) нет (или audit) полная, из коробки
Replay невозможен невозможен возможен
Новый consumer нужен свой ETL нужен свой ETL replay с начала
Когда выбирать один паттерн разные паттерны история = бизнес-
чтения, чтения, требование,
нагрузка в рамках нагрузка на аудит, replay,
одной БД чтение >> запись растущее число
потребителей
────────────────────────────────────────────────────────────────────────
Sources
- Young, 2010, CQRS Documents — оригинальное описание паттерна CQRS
- Kleppmann, 2017, Designing Data-Intensive Applications, Chapters 11-12 — event sourcing, stream processing, derived data
- Fowler, 2011, Event Sourcing — описание паттерна и trade-offs
- Richardson, 2018, Microservices Patterns — CQRS и event sourcing в контексте микросервисов
- Meyer, 1988, Object-Oriented Software Construction — CQS (Command Query Separation)