CQRS

Что такое CQRS простыми словами: зачем разделять команды и запросы, как устроены write- и read-модель, что такое отложенная согласованность и когда CQRS не нужен.

← назад к разделу

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

Обязательно

Откуда берётся проблема

Представьте интернет-магазин. Когда покупатель оформляет заказ — нужно проверить остатки, применить скидки, убедиться что адрес доставки корректен. Это сложная бизнес-логика.

Когда покупатель смотрит список своих заказов — нужно просто вывести таблицу: дата, сумма, статус. Никаких проверок, никаких правил.

Если обе операции работают через одну модель, получается компромисс: модель перегружена деталями для чтения, которые мешают логике записи, или наоборот — упрощена ради чтения, и тогда приходится выкручиваться с бизнес-правилами.

CQRS (Command Query Responsibility Segregation — разделение ответственности команд и запросов) говорит: не надо компромиссов. Разделите изменение данных и чтение данных на две независимые модели и оптимизируйте каждую отдельно.

Команды и запросы

В CQRS все операции делятся на два типа:

Команда (Command) — меняет состояние системы. Примеры: CreateOrder, CancelOrder, UpdateProfile. Команда проходит через бизнес-логику, проверяет правила, сохраняет результат. Возвращает только идентификатор или статус — не сами данные.

Запрос (Query) — читает данные. Примеры: GetOrderById, ListRecentOrders. Запрос не меняет никакое состояние, не имеет побочных эффектов. Возвращает данные в том виде, который удобен конкретному экрану.

Это означает: если клиенту нужен созданный заказ — он делает два шага: сначала команду (создать), потом отдельный запрос (получить). Команда не возвращает полный объект заказа.

Две модели: write и read

Write-модель — строгая. Здесь живут все бизнес-правила. Данные хранятся в нормализованном виде, объекты скрывают своё внутреннее устройство и дают наружу только методы, которые проверяют корректность изменений.

Read-модель — удобная. Здесь данные денормализованы и подогнаны под конкретный запрос. Вместо того чтобы джойнить пять таблиц при каждом запросе списка — read-модель уже содержит готовую сборку. Это может быть отдельная таблица, материализованное представление, кэш или поисковый индекс.

Пример: write-модель хранит заказ в трёх таблицах (orders, order_items, customers). Read-модель для списка заказов — это одна таблица order_summary_view со всеми нужными полями уже собранными вместе.

Как это выглядит в коде

Поток записи: команда приходит в обработчик, обработчик загружает объект из write-хранилища, вызывает бизнес-метод, сохраняет изменения.

Поток чтения: запрос приходит в обработчик, обработчик читает прямо из read-модели одним простым запросом, возвращает результат без участия бизнес-логики.

// Поток записи
public record CreateOrderCommand(UUID customerId, List<OrderItem> items) {}

public class CreateOrderHandler {
    public OrderId handle(CreateOrderCommand cmd) {
        Customer customer = customerRepo.find(cmd.customerId());
        Order order = customer.placeOrder(cmd.items());
        orderRepo.save(order);
        events.publish(new OrderPlaced(order.id(), order.total()));
        return order.id();
    }
}

// Поток чтения
public record OrderSummary(UUID id, String customer, BigDecimal total,
                           String status, OffsetDateTime createdAt) {}

public class OrderQueryHandler {
    public List<OrderSummary> findRecentByCustomer(UUID customerId, int limit) {
        return dsl.select(ORDER_SUMMARY_VIEW.ID,
                          ORDER_SUMMARY_VIEW.CUSTOMER_NAME,
                          ORDER_SUMMARY_VIEW.TOTAL,
                          ORDER_SUMMARY_VIEW.STATUS,
                          ORDER_SUMMARY_VIEW.CREATED_AT)
            .from(ORDER_SUMMARY_VIEW)
            .where(ORDER_SUMMARY_VIEW.CUSTOMER_ID.eq(customerId))
            .orderBy(ORDER_SUMMARY_VIEW.CREATED_AT.desc())
            .limit(limit)
            .fetchInto(OrderSummary.class);
    }
}
// Поток записи
type CreateOrderCommand struct {
	CustomerID uuid.UUID
	Items      []OrderItem
}

func (h *CreateOrderHandler) Handle(ctx context.Context, cmd CreateOrderCommand) (OrderID, error) {
	customer, err := h.customers.Find(ctx, cmd.CustomerID)
	if err != nil {
		return OrderID{}, err
	}
	order, err := customer.PlaceOrder(cmd.Items)
	if err != nil {
		return OrderID{}, err
	}
	if err := h.orders.Save(ctx, order); err != nil {
		return OrderID{}, err
	}
	return order.ID(), h.events.Publish(ctx, OrderPlaced{OrderID: order.ID(), Total: order.Total()})
}

// Поток чтения
type OrderSummary struct {
	ID        uuid.UUID
	Customer  string
	Total     decimal.Decimal
	Status    string
	CreatedAt time.Time
}

func (q *OrderQueries) FindRecentByCustomer(ctx context.Context, customerID uuid.UUID, limit int) ([]OrderSummary, error) {
	rows, err := q.pool.Query(ctx, `
		SELECT id, customer_name, total, status, created_at
		FROM order_summary_view
		WHERE customer_id = $1
		ORDER BY created_at DESC
		LIMIT $2`, customerID, limit)
	if err != nil {
		return nil, err
	}
	return pgx.CollectRows(rows, pgx.RowToStructByPos[OrderSummary])
}
// Поток записи
export class CreateOrderHandler {
  constructor({ customers, orders, events }) { Object.assign(this, { customers, orders, events }); }

  async handle({ customerId, items }) {
    const customer = await this.customers.find(customerId);
    const order = customer.placeOrder(items);
    await this.orders.save(order);
    await this.events.publish(new OrderPlaced(order.id, order.total()));
    return order.id;
  }
}

// Поток чтения
export class OrderQueryHandler {
  constructor(pool) { this.pool = pool; }

  async findRecentByCustomer(customerId, limit) {
    const { rows } = await this.pool.query(
      `SELECT id, customer_name AS customer, total, status, created_at AS "createdAt"
         FROM order_summary_view
        WHERE customer_id = $1
        ORDER BY created_at DESC
        LIMIT $2`,
      [customerId, limit],
    );
    return rows;   // простые объекты OrderSummary, без агрегата
  }
}
# Поток записи
@dataclass(frozen=True)
class CreateOrderCommand:
    customer_id: UUID
    items: list[OrderItem]


class CreateOrderHandler:
    def handle(self, cmd: CreateOrderCommand) -> OrderId:
        customer = self._customers.find(cmd.customer_id)
        order = customer.place_order(cmd.items)
        self._orders.save(order)
        self._events.publish(OrderPlaced(order.id, order.total()))
        return order.id


# Поток чтения
@dataclass(frozen=True)
class OrderSummary:
    id: UUID
    customer: str
    total: Decimal
    status: str
    created_at: datetime


class OrderQueryHandler:
    def find_recent_by_customer(self, customer_id: UUID, limit: int) -> list[OrderSummary]:
        rows = self._conn.execute(
            """
            SELECT id, customer_name, total, status, created_at
              FROM order_summary_view
             WHERE customer_id = %s
             ORDER BY created_at DESC
             LIMIT %s
            """,
            (customer_id, limit),
        ).fetchall()
        return [OrderSummary(*row) for row in rows]

Обработчик команды работает с доменными объектами и бизнес-правилами. Обработчик запроса делает один запрос к готовой read-таблице через свой слой доступа к базе (jOOQ, pgx, pg, psycopg) и возвращает простую структуру данных — ни агрегата, ни доменных методов.

Как read-модель получает данные

Если write-модель и read-модель — это разные таблицы, как данные попадают в read-таблицу?

Есть несколько способов:

Через события. После сохранения изменений write-сторона публикует событие (OrderPlaced, OrderCancelled). Отдельный обработчик слушает эти события и обновляет read-таблицу. Это самый распространённый подход в CQRS.

Через триггер в базе данных. Триггер на write-таблицах после каждой вставки или изменения дописывает строку в отдельную денормализованную таблицу. Проще в реализации, но жёстче привязывает к конкретной базе данных. А вот материализованное представление триггером так не обновишь: в PostgreSQL оно пересобирается целиком командой REFRESH MATERIALIZED VIEW, построчно в него писать нельзя.

Через фоновую задачу. Периодический процесс читает изменения и перестраивает read-модель. Подходит, когда небольшая задержка допустима.

события запись таблица исходящих брокер витрина триггер запись триггер в базе витрина фоном запись по расписанию витрина

Три пути от записи к витрине: события выходят за пределы базы через таблицу исходящих и брокер, триггер и фоновая задача остаются внутри неё.

Как выбирать между ними

События (таблица исходящих плюс брокер)Триггер в базеФоновая задача
Задержкадоли секундымгновенноинтервал запуска: минуты
Витрина в другой базеданет (только та же база)да
Несколько получателейданетнет
Пересобрать витрину зановода, проигрываниемнетда
Логика преобразованияв коде, под ревью и тестамив SQL внутри базыв коде
Цена в эксплуатацииброкер, потребители, отставаниеничего новогопланировщик
Порядок и дубливаша заботане возникаетне возникает

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

Практический порядок выбора. Витрина в той же базе и задержка не важна — фоновая задача (самое дешёвое). Витрина в той же базе и нужна свежесть — обновление в том же коде, где запись (см. ниже). Витрина в другой базе, несколько получателей, нужна возможность пересобрать — события. Триггер — только когда очень нужно «здесь и сейчас» и обе таблицы в одной базе.

Eventual consistency — данные не сразу свежие

Когда обновление read-модели происходит асинхронно (через события или фоновые задачи), между записью и видимостью результата есть задержка. Покупатель оформил заказ — а в списке своих заказов ещё секунду видит старую картину.

Это называется отложенная согласованность (eventual consistency): система в итоге придёт к правильному состоянию, но не мгновенно.

Для большинства экранов это нормально. Но если пользователь должен сразу увидеть результат своего действия — нужно либо читать из write-модели для этого случая, либо строить read-модель синхронно.

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

Как только витрина оказывается в другом хранилище (кеш, поисковый движок, аналитическая база), синхронное обновление становится ошибкой, и это уже не вкус: запись в две системы без общей транзакции означает, что одна из них рано или поздно останется без обновления, а бизнес-операция будет зависеть от доступности витрины. Разбор — в статье про витрину чтения.

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

Когда CQRS полезен

  • Нагрузка на чтение и запись сильно различается — их нужно масштабировать отдельно.
  • Доменная логика сложная, а запросы для UI хотят простую плоскую структуру.
  • Один и тот же объект нужен в разных экранах в разных формах.
  • Нужны отдельные хранилища: например, запись в PostgreSQL, а поиск в Elasticsearch.

Когда CQRS не нужен

  • Небольшой сервис с простым CRUD и невысокой нагрузкой.
  • Одна модель удовлетворяет и чтение, и запись без натяжки.
  • Команда не готова поддерживать две модели и синхронизацию между ними.

CQRS — это не серебряная пуля. Он добавляет сложность: больше кода, два хранилища, нужно следить за синхронизацией. Применяйте его когда выгода от разделения очевидна, а не на всякий случай.

Дополнительно: при первом чтении можно пропустить

Глубже: почему команда не возвращает данныерасширенное

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

Причина 1: иначе смешиваются две модели. Чтобы вернуть полную картинку, обработчику команды пришлось бы собрать её из read-модели — то есть внутри записи оказалось бы чтение, и разделение, ради которого всё делается, исчезает в первой же строке.

Причина 2: агрегат наружу не отдают. Единственное, что у обработчика есть под рукой, — доменный объект, и его отдавать нельзя: получив его, вызывающий сможет менять состояние в обход правил. Значит, нужно преобразование в структуру для ответа, а это уже работа стороны чтения.

Причина 3: при отложенной согласованности ответ всё равно был бы неполным. Команда изменила запись, а витрина обновится через мгновение — и вернуть «как теперь выглядит объект» команда честно не может, если картинка собирается из витрины.

Как выглядит контракт. Обычная форма для HTTP: POST /orders → 201 Created с заголовком Location: /orders/{id} и телом, где только идентификатор. Клиент, которому нужна картинка, делает GET по этой ссылке. Для операций без создания — 204 No Content или 200 со статусом операции.

Когда правило нарушают. Два случая, и оба осознанные. Первый: интерфейсу нужно показать результат немедленно, а лишний запрос по сети дорог (мобильное приложение на плохой связи). Тогда команда возвращает минимальную структуру, собранную из того, что уже есть в памяти обработчика: идентификатор, новый статус, итоговая сумма — не полный документ и не агрегат. Второй: операция сама по себе про вычисление («рассчитать и зафиксировать стоимость доставки») — результат расчёта и есть смысл команды, и не отдать его означает заставить клиента угадывать.

Граница между «нарушением» и «нормой» простая: команда может вернуть то, что уже знает сама, и не должна ходить за этим в витрину. Как только в обработчике команды появился запрос к стороне чтения — правило нарушено по существу, а не по форме. Подробный разбор контракта — в статье про сторону команд.

запись команда обработчик агрегат write-таблицы чтение запрос обработчик витрина плоский ответ

Два потока в одном сервисе: запись идёт через агрегат в нормализованные таблицы, чтение забирает готовую строку из витрины.

Глубже: CQRS и Event Sourcingрасширенное

Их часто называют вместе, но это два разных паттерна.

Event Sourcing — это способ хранить состояние объекта не как текущий снимок, а как последовательность событий, из которых снимок восстанавливается. Это отдельная большая тема — она разобрана в статье про Event Sourcing.

CQRS можно применять вообще без Event Sourcing. И наоборот — Event Sourcing не требует CQRS. Они хорошо сочетаются, потому что лог событий удобно использовать для обновления read-моделей, но один без другого работает отлично.

Глубже: что команда получает в эксплуатациюрасширенное

«Следить за синхронизацией» — это конкретный список забот, и лучше знать его до внедрения, а не после.

  • Отставание потребителя. Витрина обновляется медленнее, чем идут события: пользователи видят старые данные минутами вместо миллисекунд. Нужна метрика отставания и тревога на неё — иначе о проблеме сообщат пользователи.
  • Застрявшая проекция. Потребитель падает на одном «плохом» событии и переобрабатывает его бесконечно, останавливая всю очередь. Нужно ограничение попыток, очередь недоставленных и разбор — иначе витрина встаёт целиком.
  • Умение перестроить витрину. Изменили формат, нашли ошибку в преобразовании, потеряли данные — витрину надо пересобрать из источника правды. Это процедура, которую надо написать и проверить заранее, с известным временем на реальном объёме; в момент нужды её не пишут.
  • Расхождение, которое никто не заметил. Витрина тихо разошлась с записью — не «отстала», а стала неверной (пропущенное событие, ошибка в обработчике). Нужна периодическая сверка хотя бы по контрольным числам: количество записей, суммы за период.
  • Два хранилища в обслуживании. Резервные копии, обновления, права доступа, мониторинг — теперь для двух систем. И два места, где данные могут не совпасть при восстановлении из копий.
  • Схема витрины тоже меняется. Добавить поле в проекцию — это миграция плюс дозаполнение старых записей, по тем же правилам совместимости, что на стороне записи.

Порядок величины: на каждый час, сэкономленный на запросах, приходится несколько часов работы в эксплуатации в первые месяцы. Это нормальный обмен, когда запросы действительно были проблемой, и чистый убыток, когда CQRS взяли «на будущее». Как измерить, что пора, — в статье про уровни CQRS.

Коротко

  • CQRS разделяет операции на команды (меняют состояние) и запросы (читают данные). Команды проходят через бизнес-логику и возвращают только идентификатор, не данные.
  • Запросы читают из read-модели напрямую — без участия доменной логики. Write-модель строгая и нормализованная; read-модель денормализованная и удобная для UI.
  • Порядок выбора простой: пока витрина в той же базе и свежесть не критична, хватает фоновой задачи; нужна свежесть, обновляйте витрину тем же кодом, что пишет данные; другая база или несколько получателей уже требуют событий.
  • Отложенная согласованность — read-модель может отставать от write-модели на небольшое время.
  • CQRS и Event Sourcing — разные паттерны; каждый работает без другого. Полезен при высокой нагрузке, сложной доменной логике и разных формах одних данных; не нужен при простом CRUD.
  • Команда не возвращает данные потому, что иначе внутри записи оказывается чтение, агрегат наружу отдавать нельзя, а витрина всё равно отстаёт; вернуть можно только то, что обработчик уже знает сам.
  • Способ доставки в витрину выбирают по трём признакам: другая база, несколько получателей, возможность пересобрать; триггер прячет логику в базу, работает в транзакции записи и потому не основной механизм.
  • Синхронная витрина допустима только в той же базе и в той же транзакции; в другом хранилище это ошибка, а задержка складывается из интервала отправщика, времени в брокере и времени потребителя.
  • В эксплуатацию приходят отставание, застрявшая проекция, процедура перестроения с известным временем, периодическая сверка, второе хранилище и миграции схемы витрины.

Что почитать дальше

  • Когда CQRS оправдан: три ступени (Java, Go, Node, Python) — измеримые признаки и цена каждой ступени.
  • Сторона команд (Java, Go, Node, Python) — обработчик команды, транзакция, идемпотентность, что возвращать.
  • Сторона запросов (Java, Go, Node, Python) — обработчики запросов, структуры для ответа, права и постраничная навигация.
  • Витрина чтения (Java, Go, Node, Python) — где хранить, как обновлять, как перестраивать.
  • Синхронизация через события (Java, Go, Node, Python) — таблица исходящих, отправщик, порядок, дубли, отставание.
  • Уровень и эволюция (Java, Go, Node, Python) — как двигаться по уровням и как не уехать дальше нужного.
  • Гексагональная архитектура — как изолировать бизнес-логику от инфраструктуры.
  • Тактические паттерны DDD — Aggregate и Domain Event, на которых строится write-модель.
  • Стратегические паттерны DDD — Bounded Context и место CQRS в больших системах.