CQRS и Event Sourcing: всерьёз и без фанатизма
CQRS и Event Sourcing часто продают в одной коробке, как будто это одна технология. Это две разные идеи с очень разной ценой. CQRS, разделение чтения и записи, простая мысль, которую ты уже много раз видел, возможно не зная имени. Event Sourcing, хранение лога событий вместо текущего состояния, мощный, дорогой и капризный инструмент, который чаще приносят в прод зря, чем по делу.
План урока честный: сначала покажем, что CQRS вокруг тебя уже давно, потом разберём ES всерьёз, потом правила выживания для тех, кому с ним жить, и в конце поднимемся на уровень систем: ESB против MQ против потока событий.
CQRS без DDD мы уже трогали в прагматичной серии. Здесь идём глубже: с событиями, проекциями и шинами.
От CQS к CQRS
CQS это принцип уровня метода, сформулированный Бертраном Мейером. CQRS поднимает его на уровень архитектуры: у записи и чтения разные модели, возможно разные хранилища, возможно разные команды разработки.
Суть глубже, чем кажется: CQS позволяет совместить в одной программе две парадигмы. Query, это функциональный мир: чистые проекции данных, без побочных эффектов, их можно кешировать и составлять. Command, это императивный мир: изменение состояния, проверка инвариантов, транзакции. Разделив их, перестаёшь писать код, который одновременно и считает, и мутирует, и не делает хорошо ни того ни другого.
В минимальной форме CQRS не требует ни событий, ни отдельных баз, ни вообще новой инфраструктуры. Это просто две группы функций с разными правилами жизни:
// команда: проходит через домен, проверяет инварианты, в транзакции
export function makePlaceOrder(store: OrderStore, limits: LimitPolicy) {
return async (userId: string, input: NewOrderInput): Promise<void> => {
const order = Order.place(userId, input); // доменные проверки
await limits.ensureWithin(userId, order.total);
await store.save(order); // одна транзакция
};
}
// запрос: мимо домена, прямо из реплики, в форме, удобной экрану
export function makeOrdersDashboard(readDb: ReadDb) {
return (userId: string): Promise<OrderRow[]> =>
readDb.query(
`select o.id, o.total, o.status, count(i.id) as items
from orders o join order_items i on i.order_id = o.id
where o.user_id = $1
group by o.id
order by o.created_at desc
limit 50`,
[userId],
);
}
Команда обязана пройти через агрегат и его инварианты, ей нужна
транзакция и актуальное состояние. Запросу всё это балласт: ему
нужна плоская строка под конкретный экран, и читать он может с
реплики, из кеша, из денормализованной таблицы. Попытка обслужить
оба пути одной моделью даёт знакомый результат: репозиторий с
двадцатью методами findByXxxWithYyy, которые домену не нужны, а
экранам всё равно неудобны.
Дальше разделение углубляется по необходимости, и каждая ступень оплачивается отдельно: одна модель, но разные функции; разные модели в одной базе; чтение с реплики; отдельное хранилище для чтения, собираемое из событий. Большинству приложений хватает первых двух ступеней.
CQRS уже вокруг тебя
Полезное упражнение: увидеть паттерн там, где его не объявляли.
Erlang и Elixir. GenServer, базовый строительный блок OTP,
разделяет команды и запросы на уровне примитивов языка:
handle_call синхронный и возвращает значение, это Query;
handle_cast асинхронный, fire-and-forget, это Command. Процесс
инкапсулирует состояние и обрабатывает сообщения из mailbox строго
по одному. В Java или TypeScript агрегат, это паттерн, который
держится на дисциплине команды. В Elixir два потока физически не
могут одновременно изменить состояние GenServer, это гарантирует
рантайм. Мы видели эту модель в
уроке про процессы и OTP.
Стейт-менеджеры на фронте. Redux, Pinia, NgRx, это CQRS-like машины: action это команда, селектор это запрос, редьюсер это обработчик. Отсюда же растёт вечный спор «где держать бизнес-логику, в actions или в reducers», на который даже FAQ Redux не даёт однозначного ответа. Рабочий ответ из мира CQRS: на фронте доменной логики нет, если это не offline-first приложение. Задача UI, показать пользователю read model, чтобы он смог сформировать валидную команду. А это в корне другая задача, чем проверка инвариантов агрегата на бэке.
Подробно эту кухню мы разбирали в уроке про Redux, эффекты и RTK Query. Здесь важна только рифма: команды и запросы разъезжаются даже там, где слова CQRS никто не произносил, потому что у изменения состояния и у его чтения объективно разные потребности.
Event Sourcing: лог как источник истины
Обычная база хранит текущее состояние, UPDATE затирает прошлое. ES переворачивает: храним каждое событие (OrderPlaced, ItemAdded, OrderPaid), а текущее состояние выводим, проигрывая лог сначала.
Идея «лог первичен» тебе уже знакома с другого этажа: в воркшопе своя СУБД ты писал WAL, где база сначала дописывает запись в журнал и только потом меняет страницы. ES поднимает тот же принцип с уровня страниц на уровень домена: журналом становятся не байты, а бизнес-события.
Возьмём заказ с лимитом, сквозную фичу уроков про порты и про DI, и переложим на события:
import { match } from 'ts-pattern';
type OrderEvent =
| { type: 'OrderPlaced'; orderId: string; userId: string; at: string }
| { type: 'ItemAdded'; orderId: string; sku: string; price: number }
| { type: 'OrderPaid'; orderId: string; amount: number };
type OrderState = {
status: 'new' | 'paid';
total: number;
items: readonly string[];
};
const emptyOrder: OrderState = { status: 'new', total: 0, items: [] };
// состояние, это свёртка лога: чистая функция, никакого I/O
function apply(state: OrderState, event: OrderEvent): OrderState {
return match(event)
.with({ type: 'OrderPlaced' }, () => emptyOrder)
.with({ type: 'ItemAdded' }, (e) => ({
...state,
total: state.total + e.price,
items: [...state.items, e.sku],
}))
.with({ type: 'OrderPaid' }, () => ({ ...state, status: 'paid' as const }))
.exhaustive();
}
const order = events.reduce(apply, emptyOrder);
Вокруг этой свёртки собирается вся машина:
- Event store. Append-only хранилище: события дописываются в конец потока агрегата и никогда не изменяются. Команда выглядит так: прочитать поток заказа, свернуть в состояние, проверить инварианты (лимит не превышен), дописать новое событие. Никаких UPDATE, только INSERT.
- Проекции. Материализованные read model: отдельный процесс читает события и поддерживает таблицы под экраны, «заказы пользователя», «выручка за день». Проекция, это тоже свёртка, только живущая в базе для чтения. Здесь CQRS и ES смыкаются: запись пишет события, чтение ходит в проекции.
- Снапшоты. Когда в потоке агрегата тысячи событий, проигрывать их на каждую команду дорого. Снапшот, это сохранённое состояние на момент события N: восстанавливаемся из снапшота и доигрываем только хвост. Чистая оптимизация, истиной остаётся лог.
- Саги. Длинные процессы поверх событий: «заказ оплачен, значит зарезервировать товар, значит отправить письмо», с компенсациями, если шаг провалился. Мы строили их в уроке про process managers, а в полный рост, с надёжной доставкой и outbox, они вернутся в волне про микросервисы и messaging.
Этот паттерн ты уже строил руками в Gleam-треке: вся четвёрка R2, Decider, ES-агрегат, CQRS read models и process managers, это функциональная реализация того, о чём здесь говорим архитектурно. А мысль о том, что событие, это описание, которое потом интерпретируют, разворачивается в уроке про описание против интерпретации.
Что ES даёт, чего не даёт снапшот
- Полный аудит бесплатно: лог и есть аудит.
- Запросы во времени: каким был заказ в момент X.
- Новые проекции задним числом: придумал отчёт, проиграл историю.
- Отладка через воспроизведение: баг можно повторить логом.
Чего ES стоит
Eventual consistency между записью и проекциями (мост к предыдущему уроку): прочитал сразу после команды, увидел старое. Схема событий, это публичный контракт навсегда: событие нельзя поправить миграцией, оно уже случилось. Инфраструктуры становится в разы больше.
И отдельная ловушка: ES любят приносить туда, где проблема не в модели данных. Сложная логика платежа или глобальные лимиты, это аргумент про границы агрегатов и про шардирование, а не повод включать ES. Если единственное, что нужно, это история изменений, достаточно audit-таблицы.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.