Logo
Overview

CQRS: разделяй чтение и запись — паттерн, который меняет всё

May 2, 2026
10 min read

CQRS — это не «модный паттерн». Это признание простой истины: чтение и запись — разные задачи, и не надо их пытаться обслужить одной моделью.

Если вы когда-нибудь писали API, в котором POST и GET на один и тот же ресурс держатся за одну и ту же ORM-модель — поздравляю, вы знакомы с классическим CRUD. И, скорее всего, рано или поздно вы упирались в неприятный момент: запросы на чтение становятся всё сложнее (джоины, агрегаты, фильтры), запись начинает мешать чтению (или наоборот), а попытки оптимизировать читающий путь ломают пишущий.

В этот момент кто-то на ревью бросает фразу «нам нужен CQRS», и половина команды нервно кивает, потому что про CQRS все слышали, но мало кто видел его живьём. Давайте разберёмся, что это, зачем оно, и почему — это не серебряная пуля.

Если вы ещё не читали обзор Event-Driven Architecture, сделайте это позже — CQRS особенно ярко расцветает именно в событийной среде. И если вам нужен общий контекст про моделирование домена, базовая статья о Domain-Driven Design тоже не помешает.

Что такое CQRS простыми словами

CQRS (Command Query Responsibility Segregation) — паттерн, при котором операции изменения состояния (commands) и операции чтения (queries) обрабатываются разными моделями, а в сложных случаях — разными сервисами и даже разными хранилищами.

Идею в 1986 году сформулировал Бертран Мейер в принципе CQS (Command-Query Separation): метод либо что-то меняет, либо что-то возвращает, но не одновременно. CQRS — это эволюция этой идеи на уровне архитектуры, а не отдельных методов.

Если CQS — про правила хорошего тона внутри класса, то CQRS — про разделение всей системы на две стороны:

  • Write side (команды) — принимает изменения, валидирует бизнес-правила, обновляет состояние.
  • Read side (запросы) — отдаёт данные, оптимизирована под чтение, обычно денормализована и заточена под конкретные UI-сценарии.

В классическом CRUD у вас одна модель Order, которая используется и для обновления заказа, и для отображения списка заказов с подсчётом скидок и статистикой по клиенту. В CQRS — это две разные модели: одна знает про инварианты и бизнес-правила, другая — про то, как удобнее показывать данные.

Чем CQRS отличается от классического CRUD

Чтобы стало нагляднее, посмотрим на схему. Слева — классический подход, где одна модель обслуживает всё. Справа — CQRS, где write и read разнесены.

100%
graph LR
  subgraph CRUD["Классический CRUD (одна модель на всё)"]
      UI1["UI / API клиент"]
      APP1["Application Layer"]
      MODEL1["Domain Model"]
      DB1[("Реляционная БД")]
      UI1 -->|"POST / GET"| APP1
      APP1 --> MODEL1
      MODEL1 --> DB1
      DB1 --> MODEL1
      MODEL1 --> APP1
      APP1 --> UI1
  end

  subgraph CQRS_["CQRS (write и read разнесены)"]
      UI2["UI / API клиент"]
      CMD["Command Handler"]
      WRITE["Write Model (агрегаты, инварианты)"]
      WDB[("Write DB (нормализованная)")]
      BUS["Event Bus / Sync"]
      READ["Read Model (проекции под UI)"]
      RDB[("Read DB (денормализованная)")]
      QRY["Query Handler"]

      UI2 -->|"Commands"| CMD
      CMD --> WRITE
      WRITE --> WDB
      WDB -.->|"события / репликация"| BUS
      BUS --> READ
      READ --> RDB
      UI2 -->|"Queries"| QRY
      QRY --> RDB
  end

  style UI1 fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style APP1 fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style MODEL1 fill:#f0a500,stroke:#c88400,color:#fff
  style DB1 fill:#7b68ee,stroke:#5a4db2,color:#fff

  style UI2 fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style CMD fill:#50c878,stroke:#3a9a5c,color:#fff
  style WRITE fill:#f0a500,stroke:#c88400,color:#fff
  style WDB fill:#7b68ee,stroke:#5a4db2,color:#fff
  style BUS fill:#e0e0e0,stroke:#999,color:#000
  style READ fill:#50c878,stroke:#3a9a5c,color:#fff
  style RDB fill:#7b68ee,stroke:#5a4db2,color:#fff
  style QRY fill:#50c878,stroke:#3a9a5c,color:#fff

На схеме видно главное: в CRUD весь поток (и запись, и чтение) проходит через одну модель и одну базу. В CQRS у нас два независимых конвейера, связанных шиной событий или репликацией. Read DB можно перестроить с нуля, write DB — оптимизировать под транзакции, и они друг другу не мешают.

Ключевые отличия в виде таблицы

АспектКлассический CRUDCQRS
Модель данныхОдна на запись и чтениеДве независимые модели
База данныхОдна (обычно реляционная)Можно две и больше (PostgreSQL + Elasticsearch, например)
ОптимизацияКомпромисс между OLTP и OLAPКаждая сторона оптимизирована отдельно
СложностьНизкаяСущественно выше
СогласованностьStrong consistency «из коробки»Eventual consistency между write и read
Когда уместно80% типичных бизнес-приложенийСложный домен, неравный профиль нагрузки

Из чего состоит CQRS: команды, запросы и хендлеры

Чтобы CQRS не выглядел как абстракция из учебника, разложим его на конкретные кирпичики.

1. Command (команда)

Команда — это намерение пользователя что-то изменить. Это не «отправь HTTP-запрос», а бизнес-намерение в чистом виде:

  • PlaceOrder (оформить заказ)
  • CancelSubscription (отменить подписку)
  • RefundPayment (вернуть платёж)

Команда всегда формулируется в повелительном наклонении и имеет одно состояние ответа: принята или отвергнута. Никаких «верни мне обновлённую сущность» — это не задача команды.

2. Command Handler

Объект, который принимает команду, грузит соответствующий агрегат из write-хранилища, валидирует инварианты, применяет изменения и сохраняет результат. Если знакомы с DDD — это ровно тот слой, где живут ваши агрегаты и доменные сервисы.

3. Query (запрос)

Запрос — это намерение пользователя что-то прочитать. Запросы возвращают DTO, заточенные под конкретный UI-сценарий: «список заказов клиента за месяц с суммой и статусом доставки», «детальная карточка заказа», «дашборд по продажам».

В CQRS запросы не должны менять состояние — никаких «прочитал и попутно проставил last_viewed_at». Если хотите фиксировать факт просмотра — это отдельная команда MarkOrderAsViewed.

4. Query Handler

Объект, который ходит в read-модель и достаёт нужные данные. Никаких бизнес-правил, никаких агрегатов — только запрос к базе и маппинг в DTO. Часто это просто SQL-запрос или поиск в Elasticsearch.

5. Read Model (проекция)

Самая интересная часть. Read Model не повторяет структуру write-модели. Она денормализована и хранит данные в формате, удобном для конкретных запросов.

Например, write-модель хранит Order, Customer, Payment как нормализованные таблицы. А read-модель хранит готовую проекцию OrderListView с уже подтянутым именем клиента, статусом платежа и суммой со скидкой. Никаких джоинов на лету — данные уже в нужной форме.

Чтобы поддерживать read-модель в актуальном состоянии, write-сторона публикует доменные события, на которые подписаны проекции. Получили OrderCreated — добавили строку в OrderListView. Получили PaymentCompleted — обновили статус. В этот момент CQRS логично смыкается с Event-Driven Architecture, и мы переходим к следующей теме.

CQRS и Event Sourcing: почему их часто путают

Два паттерна часто упоминают вместе, и из этого рождается путаница. Давайте разделим.

Event Sourcing — паттерн, при котором текущее состояние системы не хранится напрямую, а восстанавливается из последовательности событий. Вместо «у заказа статус Paid» хранится: OrderCreated → ItemAdded → ItemAdded → PaymentRequested → PaymentCompleted.

CQRS и Event Sourcing — это разные паттерны, но они отлично друг с другом сочетаются:

  • CQRS можно делать без Event Sourcing — write-модель просто пишет в обычную реляционную БД, а проекции обновляются через триггеры или CDC (Change Data Capture).
  • Event Sourcing можно делать без CQRS — но это редко имеет смысл, потому что восстанавливать сложные read-сценарии из событий по запросу — мучительно дорого.

Когда они вместе, картинка становится особенно красивой:

100%
flowchart TD
  USER["Пользователь / API клиент"]
  CMD["Command: PlaceOrder"]
  HANDLER["Command Handler"]
  AGG["Aggregate: Order"]
  ESTORE[("Event Store")]
  EVT1["OrderCreated"]
  EVT2["ItemAdded"]
  EVT3["PaymentCompleted"]
  BUS["Event Bus"]
  PROJ1["Projection: OrderListView"]
  PROJ2["Projection: CustomerStats"]
  PROJ3["Projection: AnalyticsCube"]
  RDB1[("Read DB: PostgreSQL")]
  RDB2[("Read DB: Redis cache")]
  RDB3[("Read DB: ClickHouse")]
  QRY["Query Handler"]
  UIREAD["UI: списки, дашборды, отчёты"]

  USER --> CMD --> HANDLER --> AGG
  AGG --> EVT1 --> ESTORE
  AGG --> EVT2 --> ESTORE
  AGG --> EVT3 --> ESTORE
  ESTORE --> BUS
  BUS --> PROJ1 --> RDB1
  BUS --> PROJ2 --> RDB2
  BUS --> PROJ3 --> RDB3
  UIREAD --> QRY
  QRY --> RDB1
  QRY --> RDB2
  QRY --> RDB3

  style USER fill:#4a90d9,stroke:#2c5f8a,color:#fff
  style CMD fill:#50c878,stroke:#3a9a5c,color:#fff
  style HANDLER fill:#50c878,stroke:#3a9a5c,color:#fff
  style AGG fill:#f0a500,stroke:#c88400,color:#fff
  style ESTORE fill:#7b68ee,stroke:#5a4db2,color:#fff
  style EVT1 fill:#e0e0e0,stroke:#999,color:#000
  style EVT2 fill:#e0e0e0,stroke:#999,color:#000
  style EVT3 fill:#e0e0e0,stroke:#999,color:#000
  style BUS fill:#f0a500,stroke:#c88400,color:#fff
  style PROJ1 fill:#50c878,stroke:#3a9a5c,color:#fff
  style PROJ2 fill:#50c878,stroke:#3a9a5c,color:#fff
  style PROJ3 fill:#50c878,stroke:#3a9a5c,color:#fff
  style RDB1 fill:#7b68ee,stroke:#5a4db2,color:#fff
  style RDB2 fill:#7b68ee,stroke:#5a4db2,color:#fff
  style RDB3 fill:#7b68ee,stroke:#5a4db2,color:#fff
  style QRY fill:#50c878,stroke:#3a9a5c,color:#fff
  style UIREAD fill:#4a90d9,stroke:#2c5f8a,color:#fff

На схеме хорошо видно, почему эти паттерны такие хорошие соседи: write-сторона пишет события в Event Store, шина событий разносит их по проекциям, каждая проекция строит свою read-модель в подходящем хранилище. Хотите аналитику в ClickHouse, оперативные списки в PostgreSQL и горячий кеш в Redis — пожалуйста, никто не мешает.

И, кстати, если завтра аналитики попросят новый отчёт — вы просто пишете новую проекцию и прокручиваете все события заново. Read-модель восстанавливается из истории. Это та самая магия, ради которой люди и связываются с Event Sourcing.

Когда CQRS оправдан, а когда — нет

Самая частая ошибка с CQRS — внедрить его «потому что красиво». Через полгода команда начинает ненавидеть этот паттерн, потому что любая мелочь требует обновления двух моделей и трёх проекций. Поэтому давайте честно: CQRS уместен далеко не везде.

Когда CQRS реально окупается

  • Сложный домен с богатыми бизнес-правилами. Если у вас десятки инвариантов на агрегат и сложная валидация, отделение write-модели — спасение.
  • Сильно асимметричная нагрузка. Например, читаем в 100 раз чаще, чем пишем (типичный e-commerce каталог). Read-модель можно масштабировать независимо.
  • Разные форматы данных под разные UI. Мобильное приложение, веб-кабинет, BI-дашборд — каждому нужна своя проекция, и заставлять одну модель угодить всем — это дорого.
  • Аудит и история изменений как требование. Здесь CQRS вместе с Event Sourcing решает задачу почти бесплатно.
  • Команды работают над write- и read-сторонами параллельно. Деление по моделям становится естественной границей ответственности.

Когда CQRS только навредит

  • Простые CRUD-приложения. Админка, справочник, типовая внутренняя система — здесь два слоя моделей создадут больше проблем, чем решат.
  • Небольшая команда без опыта. CQRS требует дисциплины и понимания eventual consistency. Без этого получится неподдерживаемое месиво.
  • Жёсткое требование strong consistency. Если бизнес не готов к тому, что после команды read-модель обновится через 50–500 миллисекунд, CQRS уже на старте создаёт проблему.
  • Слабо сформулированный домен. Если вы ещё не уверены, какие у вас bounded contexts и что считается агрегатом — сначала разберитесь с этим, и только потом думайте про CQRS. Здесь как раз очень помогает DDD простыми словами.

Типичные подводные камни

Допустим, вы взвесили все «за» и решили: да, нам нужен CQRS. Прекрасно. Теперь о граблях.

1. Eventual consistency между write и read

Между моментом, когда вы обработали команду, и моментом, когда обновилась read-модель, проходит время. Иногда — миллисекунды, иногда — секунды (если консьюмер тормозит). Поэтому классический сценарий «нажал кнопку → перешёл на следующую страницу → не вижу свои изменения» становится нормой.

Решается одним из трёх способов:

  • UX-приёмы: «ваш заказ создан, обновление списка может занять пару секунд».
  • Optimistic UI: на фронте сразу подкладываем ожидаемое состояние, не дожидаясь read-модели.
  • Read-from-write для критичных сценариев: в редких случаях читаем напрямую с write-стороны.

2. Дублирование логики

Без должной дисциплины вы быстро получите два места, где «почти одно и то же»: валидаторы на write-стороне и валидаторы на read-стороне (например, для фильтрации). Поэтому дисциплина простая: бизнес-правила живут только на write-стороне. Read-сторона ничего не валидирует — она только показывает.

3. Стоимость инфраструктуры

CQRS — это, минимум, +1 база данных, +1 шина событий, +1 пайплайн проекций, +1 набор алёртов. Готовьтесь к росту операционной сложности. Команды, которые недооценивают этот пункт, чаще всего и разочаровываются в паттерне.

4. Перестройка проекций

Рано или поздно вам понадобится изменить формат read-модели. Если у вас есть Event Store — отлично, проект перепрогнал события и собрал новую проекцию. Если Event Store нет — придётся либо мигрировать данные, либо писать сложные сценарии backfill из write-БД.

5. Соблазн «всё через события»

CQRS работает лучше всего, когда в системе уже есть культура работы с событиями и stateless-сервисами. Если вы внедряете CQRS на хаосе из синхронных вызовов и общих БД — будет больно. Сначала наводим порядок в архитектуре, потом добавляем CQRS.

Заключение

CQRS — это не про моду и не про «делать как у Netflix». Это про честное признание факта: операции изменения и операции чтения предъявляют к данным очень разные требования, и одна модель на всё — это всегда компромисс. Иногда компромисс терпимый. Иногда — невыносимый.

Если у вас типовое CRUD-приложение и команда из трёх человек — оставьте CQRS в покое и спокойно живите с одной моделью. Если же домен сложный, нагрузка асимметричная, требований к проекциям много, а команда готова к eventual consistency — CQRS даст вам ту самую гибкость, ради которой стоит платить операционным усложнением.

И главное правило: не начинайте с CQRS. Начинайте с понятных границ, читаемой write-модели и спокойных запросов. CQRS появится сам, когда вы услышите от тимлида фразу «мы тут собираемся вынести чтение в отдельную базу, потому что иначе всё ляжет». Это и будет момент, когда паттерн перестанет быть теорией и станет инструментом.

PS. Если вы только что прочитали этот пост и собираетесь на завтрашнем созвоне предложить «давайте перепишем всё на CQRS» — пожалуйста, не надо. Перечитайте раздел «когда CQRS только навредит», заварите чай и подумайте ещё раз. Серьёзно, чай иногда спасает архитектуру лучше любого паттерна.