CQRS

CQRS: разделение моделей чтения и записи. Когда применять, плюсы и минусы.

CQRS

Все примеры в статье — на сквозном кейсе сайта: высоконагруженный маркетплейс.

Что такое CQRS

CQRS (Command Query Responsibility Segregation) — это паттерн, в котором операции изменения состояния и операции чтения разделены на две независимые модели:

  • Command изменяет состояние системы: create, update, delete. Возвращает только статус или идентификатор, но не данные домена.
  • Query читает данные. Никогда не меняет состояние, не имеет сайд-эффектов.

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

Базовые принципы

1. Команды не возвращают данные домена. Команда CreateOrder возвращает OrderId, а не объект Order. Если клиенту нужны полные данные — он делает отдельный запрос через Query.

2. Запросы не меняют состояние. Никаких аудитных записей, обновлений last_seen, ленивых вычислений с побочными эффектами. Query — это идемпотентная читалка.

3. Разные модели для чтения и записи.

  • Write-model: строгая доменная модель, инварианты, бизнес-правила. Часто нормализована, прячется за агрегатами.
  • Read-model: денормализованная, оптимизированная под конкретный запрос UI. Может строиться материализованными представлениями, отдельными таблицами, поисковым индексом или кэшем.

Типичная архитектура

Поток записи:

  1. Команда приходит в обработчик
  2. Загружается агрегат из write-store
  3. Вызывается доменный метод, который проверяет инварианты
  4. Изменения сохраняются в write-store
  5. (Опционально) публикуется доменное событие
  6. Read-модель обновляется по событию

Поток чтения:

  1. Запрос приходит в обработчик
  2. Чтение прямо из read-модели (часто одним SQL-запросом)
  3. Возврат DTO без участия доменной логики
// Write side
public record CreateOrderCommand(UUID customerId, List<OrderItem> items) {}

@Component
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();
    }
}

// Read side
public record OrderSummary(UUID id, String customer, BigDecimal total, String status) {}

@Component
public class OrderQueryHandler {
    public List<OrderSummary> findRecentByCustomer(UUID customerId, int limit) {
        return jdbc.query("""
            SELECT id, customer_name, total, status
            FROM order_summary_view
            WHERE customer_id = ?
            ORDER BY created_at DESC LIMIT ?
            """, OrderSummary.class, customerId, limit);
    }
}

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

  • много чтения и сложная выборка из разных таблиц
  • нужно масштабировать чтение отдельно от записи
  • доменная логика сложная, а UI хочет простой плоский ответ
  • разные представления одних и тех же данных в разных экранах

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

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

Преимущества

  • отдельное масштабирование чтения и записи
  • read-модель можно оптимизировать под UI без компромиссов в домене
  • доменная модель не засоряется запросами «дай 10 полей для таблицы»
  • проще разделять ответственность handler-ов команд и запросов

Недостатки

  • больше кода: две модели, два хранилища, один pipeline между ними
  • eventual consistency: read-модель может отставать от write-модели на миллисекунды или секунды; пользователь увидит «свежий» заказ не сразу после POST
  • нужно строить и поддерживать pipeline обновления read-модели — это отдельный источник багов

CQRS и Event Sourcing

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

Ссылки