lenec ru

← все посты

CQRS на практике: когда стоит, а когда это просто два слоя для одного запроса

10K

CQRS (Command Query Responsibility Segregation) — паттерн, про который чаще говорят, чем по-настоящему применяют. Идея простая: команды (изменяющие операции) и запросы (читающие) — это разные модели, разные слои, иногда разные хранилища. На бумаге звучит хорошо. В коде у трёх из четырёх команд, что я видел, CQRS выглядит как «ой, у нас CommandHandler и QueryHandler, но обе работают с одной таблицей» — и это не CQRS, это просто два слоя для одного запроса.

За двенадцать лет я внедрял CQRS в трёх вариантах сложности: «лёгкий» (разделение моделей в коде), «средний» (разные read-модели и проекции) и «тяжёлый» (CQRS+ES с разными базами под чтение и запись). Разберу, в каких случаях какой вариант оправдан, и где CQRS превращается в карго-культ.

В чём суть

В обычной архитектуре одна модель сущности обслуживает и запись, и чтение. Order-класс используется в обработчике POST /orders и в GET /orders. Одна и та же ORM-сущность, одни и те же поля.

Это работает, пока запросы не начинают расходиться по форме. Запись хочет богатую модель с инвариантами и валидацией. Чтение хочет плоский DTO для конкретной странички с заранее посчитанными агрегатами. Втиснуть оба в одну сущность — компромисс с обеих сторон.

CQRS говорит: разделите. Команды работают через одну модель (доменную, с инвариантами, обычно через репозиторий и агрегат). Запросы — через отдельную модель чтения, оптимизированную под use-case (плоский DTO, заранее агрегированные поля, иногда отдельная БД).

// Запись: команда + обработчик + агрегат
class CreateOrderCommand(val userId: UUID, val items: List<OrderItem>)

class CreateOrderHandler(private val repo: OrderRepository) {
    fun handle(cmd: CreateOrderCommand): UUID {
        val order = Order.create(cmd.userId, cmd.items)
        repo.save(order)
        return order.id
    }
}

// Чтение: отдельный read-model
data class OrderListItem(
    val id: UUID,
    val userId: UUID,
    val status: String,
    val totalAmount: BigDecimal,
    val itemsCount: Int,
    val createdAt: Instant
)

class OrderListQuery(private val jdbc: NamedParameterJdbcTemplate) {
    fun byUser(userId: UUID, limit: Int): List<OrderListItem> =
        jdbc.query(
            """SELECT id, user_id, status, total_amount, items_count, created_at
               FROM v_order_list
               WHERE user_id = :uid
               ORDER BY created_at DESC
               LIMIT :lim""",
            mapOf("uid" to userId, "lim" to limit),
            ::mapRow
        )
}

Запись — через объект, агрегат, репозиторий. Чтение — через простой SQL в DTO. Никакого ORM-маппинга на чтение, никакого Order-класса в QueryHandler.

Уровни CQRS

Три уровня, которые я различаю.

Уровень 1: Разделение моделей в коде

Самый простой и самый частый. Команды и запросы живут в разных классах, используют разные модели данных, но физически — та же БД, те же таблицы.

Что выигрываете:

  • Чтение не тащит за собой инварианты домена. Запрос «список заказов с агрегатами» — это просто SQL, не order.calculateTotal() на каждом элементе.
  • Команды чище: только бизнес-логика, без оптимизаций под чтение.
  • Тестируется проще: команды тестируются через домен, запросы — через интеграционные тесты с БД.

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

Это разумный дефолт для большинства проектов. Я обычно с него и начинаю.

Уровень 2: Разные read-модели

Появляются отдельные таблицы или материализованные представления для чтения. Записи идут в нормализованные таблицы (orders, order_items), а чтение — из денормализованной (order_list_view: с уже посчитанным total и количеством items).

Эти view либо триггерятся на изменения исходных таблиц, либо обновляются по событиям.

-- Простейший вариант: материализованное представление
CREATE MATERIALIZED VIEW order_list_view AS
SELECT 
    o.id, o.user_id, o.status, o.created_at,
    SUM(i.price * i.quantity) AS total_amount,
    COUNT(i.id) AS items_count
FROM orders o
JOIN order_items i ON i.order_id = o.id
GROUP BY o.id;

CREATE INDEX ON order_list_view (user_id, created_at DESC);

Что выигрываете:

  • Запросы быстрые. Никаких JOIN'ов и агрегаций на чтение.
  • Запись не страдает от индексов под чтение.
  • Можно построить специализированные представления под каждый use-case.

Что теряете:

  • Eventually consistent: между изменением заказа и обновлением view проходит время.
  • Сложность инфраструктуры: процесс обновления view надо мониторить.
  • Дублирование данных: storage больше.

Уровень 3: Разные хранилища

Запись — в одной БД (например, Postgres), чтение — в другой (Elasticsearch, Redis, ClickHouse). Между ними — пайплайн, который слушает события и обновляет read-store.

Применяется в системах с принципиально разной природой запросов: запись — транзакционная, нормализованная, согласованная. Чтение — поиск с фасетами, аналитика по миллиардам строк, agregации в реальном времени.

Что выигрываете: реальная независимость масштабирования и оптимизации под каждую сторону.

Что платите: всё, что в уровне 2, плюс ещё одна БД, ещё один пайплайн, ещё одна точка отказа, ещё одна команда людей, которые её знают.

Когда какой уровень

Простые правила, которыми я пользуюсь.

Уровень 1 — почти всегда оправдан. Стоимость нулевая, профит — чище код. Я прихожу на проект и сразу вижу, есть ли разделение между обработчиками команд и обработчиками запросов. Если нет — рекомендую вводить.

Уровень 2 оправдан, когда:

  • Чтения сильно перевешивают записи (10+ к 1).
  • На некоторые запросы нужны агрегации/денормализация, которые на лету тормозят.
  • Команда готова жить с eventual consistency на этом куске.

Уровень 3 оправдан, когда:

  • Запись и чтение — принципиально разные технологии (например, OLTP-нагрузка vs full-text search).
  • Объёмы данных такие, что одна БД физически не справляется с обеими ролями.
  • Есть ресурс эксплуатировать два хранилища.

Если вы не можете чётко обозначить, что выигрываете на уровне 3, вы на нём не нужны.

CQRS и event sourcing

Часто путают. Это разные паттерны, и они независимы.

CQRS — про разделение моделей чтения и записи. Можно делать с обычной CRUD-БД на запись.

Event sourcing — про хранение истории изменений вместо состояния. Можно делать без CQRS, если у вас одна модель и для команд, и для проекций (но это редко).

Они хорошо сочетаются: event sourcing даёт «события», на которые натуральным образом подписываются проекции CQRS. Но не обязательны вместе.

Я внедрял CQRS без ES чаще, чем с ES. CQRS+ES — это уровень сложности, который требует серьёзного основания (про event sourcing я писал отдельно: его цена входа высокая).

Что обычно делают неправильно

Антипаттерны, которые я регулярно вижу.

Псевдо-CQRS на одной модели

Команды и запросы — отдельные классы, но обе работают с одной ORM-сущностью OrderEntity. Чтение мапит её в DTO «вручную», запись использует напрямую. Это не CQRS, это лишний слой бесполезной работы.

Лекарство: либо честное разделение моделей (DTO для чтения не зависит от ORM-сущности), либо отказ от CQRS на этом куске.

Команды-геттеры

«Получить статус заказа» оформлено как Command. Концептуально это запрос, но команда — потому что «у нас CQRS, всё через команды». Это путаница терминологии.

Команда = изменяющая операция, возвращает не данные, а подтверждение. Запрос = чтение, не меняет состояние, возвращает данные. Если ваша «команда» ничего не меняет — это не команда.

Шина команд ради шины

Внедряют MediatR/CommandBus и пропускают через него все операции. Каждый CRUD-эндпоинт — это команда, которая летит через шину к хендлеру. Лишний indirection, никакой пользы.

Шина оправдана, когда есть горизонтальные concerns (логирование, валидация, авторизация на уровне команд) и команд много. На пяти эндпоинтах — оверкилл.

Eventually consistent там, где не нужно

Команда «создать заказ» возвращает 202 Accepted. Через 500 мс заказ появится в read-модели. Фронтенд ждёт, делает poll. UX страдает, бизнес-польза неочевидна.

На уровне 1 CQRS этого нет: чтение видит изменение сразу, потому что таблица одна. На уровне 2-3 eventual consistency — это плата, и её надо обосновать use-case'ом, а не модой.

Практические советы

Несколько вещей, которые я бы хотел знать раньше.

Не вводите CQRS «на всю систему». Часть домена выигрывает, часть — нет. Заказы — да, профили пользователей — нет. Гибридная архитектура нормальна.

Read-модель — отдельный артефакт. Это не «view sql из commit'a». Это явно описанный класс/таблица с собственным контрактом, версионируемая, тестируемая.

Не превращайте read-модель в свалку. Один use-case — одна view. Не «одна таблица на все возможные запросы». Иначе она быстро становится недопустимо широкой и медленной.

Eventual consistency — UI-задача. Если read-модель отстаёт, UI должен это коммуницировать пользователю. Optimistic updates на стороне фронта, индикаторы синхронизации, retry — это всё нужно решить, иначе пользователь видит «я создал заказ, но в списке его нет».

Мониторьте лаг проекций. Метрика «отставание read-модели от write-модели» — критичная. Если оно стабильно растёт, значит, проекция не успевает, и пользователи видят несвежие данные.

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

Список симптомов «вам это не нужно».

  • Простой CRUD-домен. Профили, настройки, справочники.
  • Чтение и запись одинакового объёма, без специальных агрегаций.
  • Маленькая команда, маленький продукт. Накладные расходы на разделение слоёв перевешивают пользу.
  • Нет реальной боли с производительностью или сложностью моделей. Если боли нет, вы лечите несуществующую проблему.

Что запомнить

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

Самый частый антипаттерн — CQRS «по фен-шую» без реальной выгоды. Если вы не можете назвать конкретную причину, по которой ваш проект выигрывает от разделения, вы делаете архитектуру для тренировки, а не для продукта. Хорошая архитектура — та, у которой ясная цена и ясная польза. CQRS не исключение.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.