Раздел 9 · Backend

CQRS и Event Sourcing: всерьёз и без фанатизма

senior~35 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

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.