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