CQRS pattern — separate write and read sides connected via event bus
CQRS нужен, когда одна CRUD-модель начинает одновременно обслуживать два разных мира: строгую запись с инвариантами и быстрые чтения под конкретные экраны. В write path важны транзакции, нормализация, проверка бизнес-правил, идемпотентность команд и понятная точка консистентности. В read path важны форма ответа, индексы, денормализация, кеширование и способность выдерживать поток GET-запросов, который часто на порядки больше потока изменений.
Если держать всё в одной схеме, команда постепенно платит скрытый налог: сложные JOIN-ы, неочевидные индексы, read-specific поля в write таблицах, кеши поверх кешей и спор о том, «чья» модель данных правильная. CQRS делает этот конфликт явным. Команды меняют write model, queries читают read model, а между ними работает проекция событий или изменений. Это не бесплатная оптимизация, а архитектурное решение: вы покупаете независимость read/write ценой асинхронности и операционной сложности.
Паттерн особенно важен для систем, где одно бизнес-событие должно породить несколько представлений: список заказов для кабинета, карточку заказа, поисковый индекс, аналитический агрегат, уведомления и отчётность. Он также подготавливает почву для event-sourcing, transactional-outbox и change-data-capture: write side становится источником фактов, а read side перестаёт быть местом, где живут бизнес-инварианты.
Write model отвечает на вопрос: «можно ли это изменение принять и как сохранить факт корректно?». Read model отвечает на вопрос: «как отдать данные быстро и в форме, удобной конкретному потребителю?». Между ними стоит projector, который переводит факты write side в материализованные представления.
Хорошая аналогия: бухгалтерский журнал и витрина отчётов. Журнал должен быть строгим, последовательным и проверяемым. Витрины можно перестраивать, кешировать, денормализовать и делать разными для разных задач. Если витрина отстаёт на 100-500 мс, это может быть нормально; если журнал потерял инвариант, это уже авария.
Важно не путать CQRS с Event Sourcing. CQRS говорит «раздели запись и чтение». Event Sourcing говорит «храни события как источник правды». Они часто идут вместе, но возможны разные комбинации: CQRS поверх обычной нормализованной БД через CDC, Event Sourcing без отдельной read side для простых задач, или классическая связка CQRS + Event Store + projections.
Диаграмма показывает минимальную, но production-похожую CQRS-схему. Слева есть отдельный клиентский write path: client-write отправляет команды POST/PUT в Command Handler. Handler валидирует команду, проверяет бизнес-правила, добавляет событие в Event Store и публикует его в Event Bus. Это сторона, где живут инварианты: нельзя создать заказ без товаров, нельзя списать деньги дважды, нельзя отменить уже отгруженный заказ.
Справа расположен read path: Projector подписан на Event Bus и обновляет две materialized views. В примере это read-model-1 для списка заказов и read-model-2 для запросов по customer_id. client-read не ходит в command handler и не собирает данные через write schema; он читает напрямую из read models, которые уже имеют нужную форму.
Отдельно показан главный риск: stale read. Клиент отправляет команду, получает 201 Created, затем сразу делает GET /orders/list и может не увидеть новый заказ, потому что projector ещё не догнал event bus. Диаграмма не скрывает эту цену: CQRS требует проектировать UX, API и monitoring вокруг projection lag.
Write path учит, что command не является обычным запросом «сохрани и верни мне всё». Команда выражает намерение: создать заказ, изменить профиль, отменить подписку. Она должна быть валидирована на write side, записана как факт и подтверждена клиенту. В ADR-терминах решение такое: write model оптимизирована под consistency и validation, а не под удобство экранов. Альтернатива — один CRUD endpoint, который и проверяет инварианты, и собирает read response. Она проще в начале, но быстро превращает write schema в компромисс.
Projection показывает, что событие после записи не «само магически появляется в UI». Его должен обработать projector. Он читает event bus и обновляет несколько read views. Это место, где нужно думать об идемпотентности, порядке событий, schema evolution и replay. ADR: projector должен быть детерминированным и повторяемым, потому что рано или поздно read model придётся перестроить.
Read path показывает выгоду CQRS: запрос к read model получает данные за миллисекунды без тяжёлых JOIN-ов и без риска сломать write-инварианты. Решение: read-side schema может быть некрасиво денормализованной, если она идеально отвечает на нужный query pattern. Это нормально, потому что источником правды остаётся write side.
Stale read trade-off учит не продавать CQRS как «ускорим всё без последствий». Последствие есть: read-your-writes больше не гарантирован автоматически. Команда должна выбрать стратегию: optimistic UI, polling до нужного event_id, WebSocket-уведомление, чтение свежих данных с write side для автора изменения или строгий SLA на projection lag.
ADR: разделять ли модели? Контекст: одна схема перегружена требованиями записи и чтения. Варианты: оставить CRUD и оптимизировать индексы; добавить кеши и materialized views вокруг той же модели; перейти на CQRS. Решение в пользу CQRS оправдано, когда read patterns сложные и стабильные, а write side должна защищать инварианты. Цена: две модели, pipeline событий, lag, более сложные миграции и диагностика.
ADR: event bus или CDC? Event bus удобен, когда доменные события уже являются частью модели и команды явно публикуют OrderCreated, PaymentCaptured, ProfileUpdated. CDC удобен, когда есть существующая write БД и нужно строить projections без переписывания доменного слоя. Event bus лучше выражает смысл, CDC лучше для прагматичной миграции. В обоих вариантах нужен outbox или иной способ не потерять событие между записью в БД и публикацией.
ADR: одна read model или несколько? Одна универсальная read DB проще в эксплуатации, но снова создаёт компромиссную схему. Несколько projections дороже: нужно больше storage, больше replay-процессов, больше monitoring. Зато каждый consumer получает структуру под свою задачу: поиск может жить в Elasticsearch, аналитика в ClickHouse, список заказов в Postgres, горячие карточки в Redis.
ADR: синхронная или асинхронная projection? Синхронная кажется безопаснее для read-your-writes, но превращает команду в распределённую транзакцию и связывает write latency с read side. Асинхронная сохраняет независимость, но требует UX-компенсации и наблюдаемости. В большинстве production CQRS асинхронность является осознанным решением, а не временным упрощением.
EventStoreDB и Axon Framework построены вокруг CQRS/Event Sourcing как основной модели. Marten в .NET даёт Event Store и projections поверх Postgres. Kafka Streams, Debezium и Postgres часто используются для DIY-варианта: изменения write side идут в Kafka, а stream processors собирают read views. В e-commerce write side отвечает за заказ и оплату, read side — за каталог заказов, поиск, рекомендации и аналитику. В банковских и страховых системах CQRS часто появляется рядом с audit trail: запись должна быть строгой, а чтение — многообразным.
В CloudArch эта диаграмма связана с event-sourcing, потому что Event Store естественно становится источником событий для projections. Также стоит смотреть transactional-outbox: без него публикация события после успешной транзакции легко становится источником потерь или дублей. change-data-capture полезен как мост из существующей CRUD-системы к CQRS без полной переделки доменной модели.
Самая частая ошибка — внедрить CQRS в простой CRUD, где один экран, одна таблица и нет асимметрии read/write. Команда получает eventual consistency, два набора DTO и pipeline, но не получает выигрыша.
Вторая ошибка — держать бизнес-логику в projections. Read side должна выводить представления, а не решать, можно ли списать деньги или отменить заказ. Если projection начинает защищать инварианты, архитектура теряет смысл.
Третья ошибка — делать команды, которые возвращают полноценные read-модели. Это тянет query responsibility обратно в write path и ломает идемпотентность. Команда может вернуть id, version, event_id или статус принятия, но не обязана собирать экран.
Четвёртая ошибка — не проектировать replay. Изменение read schema, исправление бага projector-а или добавление новой projection потребует повторной обработки истории. Если события не версионированы, projector недетерминированный, а replay никогда не запускался на staging, восстановление станет отдельным инцидентом.
Пятая ошибка — не мерить projection lag. Без метрик event_bus_lag, projection_last_event_id, projection_error_rate stale reads превращаются в жалобы пользователей, а не в наблюдаемое состояние системы.
Не используйте CQRS для административного CRUD, небольших внутренних панелей и сервисов, где чтение и запись имеют одинаковую модель и умеренную нагрузку. Не используйте его, если продукт требует строгого read-your-writes для каждого действия, а команда не готова строить компенсации. Не используйте CQRS как замену нормальным индексам: если проблему решает один индекс или materialized view в той же БД, начните с этого.
CQRS также плох, когда команда не готова владеть event-driven эксплуатацией: retries, idempotency, schema registry, replay, мониторинг lag, backfill, DLQ. Паттерн не уменьшает сложность, он переносит её из SQL-запросов и ORM-моделей в архитектурный pipeline.
event-sourcing — как хранить события как источник правды и перестраивать state через replay.transactional-outbox — как атомарно сохранить изменение и событие для последующей публикации.change-data-capture — как строить read models из изменений БД.replication — почему read side часто похожа на специализированную реплику.