Привет, Хабр!

Если вы тимлид или архитектор, и в команде всё чаще звучит «давай сделаем CQRS» — стоит остановиться. Этот паттерн мощный, но далеко не беспроблемный.

Зачем вообще вспоминать о CQRS, если есть Entity Framework и SaveChanges()?

Проблема — в нагрузке. Любой монолит когда‑нибудь упирается в диск/CPU/команду DevOps. CQRS предлагает разделить модели чтения и записи, оптимизируя их независимо. В теории ─ вроде ок:

  • Write‑модель заточена под транзакционную целостность, минимум индексов, максимум through‑put.

  • Read‑модель ломится от индексов, денормализации и кэширования.

Microsoft рисует это на своих красивых диаграммах уже много лет. Но цена вопроса — сложность.

Лишний оверхед для банального CRUD

У нас простая табличка Customers, может просто сделать DbSet<Customer>? В CQRS на один такой Customer внезапно появляется:

Слой

Что появляется

Команды

CreateCustomerCommand, UpdateCustomerCommand, …

Обработчики

CreateCustomerHandler, UpdateCustomerHandler, …

DTO

CustomerDto, CustomerDetailsVm, …

Маппинг

AutoMapper‑профили туда‑сюда

Тесты

минимально ×2: на write и на read

Это шесть файлов вместо одной сущности. И пока вы смотрите на pull‑request, бизнес ждёт фичу. Именно поэтому на StackOverflow плакали, что CQRS избыточен в data‑heavy формах

Пример:

Без CQRS

public async Task<IActionResult> Post(CustomerDto dto) {
    var entity = _mapper.Map<Customer>(dto);
    _db.Customers.Add(entity);
    await _db.SaveChangesAsync();
    return Ok();
}

С CQRS + MediatR

// 1. Command
public record CreateCustomerCommand(string Name, string Email) : IRequest<Guid>;

// 2. Handler
public class CreateCustomerHandler : IRequestHandler<CreateCustomerCommand, Guid>
{
    private readonly AppDbContext _db;
    public async Task<Guid> Handle(CreateCustomerCommand cmd, CancellationToken ct)
    {
        var entity = new Customer { Name = cmd.Name, Email = cmd.Email };
        _db.Customers.Add(entity);
        await _db.SaveChangesAsync(ct);
        return entity.Id;
    }
}

Добавьте к этому проектирование read‑модели, и бонусом — задержка, о которой ниже.

Если у вас нет явной причины (трафик на чтение > трафика на запись, микросервис живёт автономно, нужна денормализация), не трогайте CQRS.

Конечная согласованность

Есть кейс в Amazon, когда товар положили в корзину, а при оплате корзина пуста.

Причина банальна: write‑модель отстрелялась (команда выполнена), а read‑модель ещё не проиндексировала событие.

Как воспроизвести баг локально

  1. Создаём команду AddToCart.

  2. В CartReadModel слушаем ProductAdded и апдейтим denormalized view.

  3. Перед запуском параллельно стреляем 1000 команд.

  4. Между AddToCart и GET /cart — 100 мс. Часть запросов вернёт пустую корзину.

// псевдокод
Parallel.For(0, 1000, _ =>
{
    _bus.Send(new AddToCart(userId, productId));
    var cart = _api.GetCart(userId); // шанс увидеть прошлую версию
});

Смягчаем боль

  • Read‑your‑write: после команды делаем прямой запрос к write‑storage (Postgres → RETURNING)

  • Versioning/ETag: фронт ждёт, пока cart.versioncommandVersion.

  • Outbox Pattern: событие публикуется только после коммита транзакции; гарантирует, что read‑проекция получит все ивенты в нужном порядке.

  • Пессимистичный UX: disable кнопка «Pay» до подтверждения синхронного RPC.

Частные vs публичные данные

Делите данные на private draft и public published. В системах управления контентом это очевидно («Черновик» и «Опубликовать»), но для интернет‑магазина звучит непривычно: «Корзина — приватная, Заказ — публичный».

Пока объект живёт в приватной зоне, вы можете мириться с eventual consistency, потому что видит его ровно один пользователь. Когда же объект становится публичным (товар появился в «Рекомендациях»), лаг превращается в расхождение ценников у разных юзеров.

Отсюда простое правило:

Плохой API
POST /products    // сразу публично

Хороший API
POST /products/drafts
POST /products/{id}/publish   // явный переход

Так избегаем неожиданного всплытия неподготовленных данных и сигнализируем бизнесу момент потери контроля.

Масштабирование: когда read-модель не успевает за write-моделью

При пиковых нагрузках очередь событий обгоняет проектор. Read‑модель отстаёт на секунды, минуты, часы. На InfoQ об этом писали — «Day Two Problems».

Вертикальный срез против традиционных слоёв

Строим фичу как вертикальный срез — от контроллера до БД — и тестируем её изолированно.

На практике вертикальный срез + CQRS выглядит так:

/Features
  /Orders
    Create.cs
    CreateValidator.cs
    CreateHandler.cs
    OrderReadModel.cs

Каждая папка — мини‑резерт. Хотите масштабировать? Деплойте /Orders отдельно, а Users — в другом контейнере. Read‑проекции отделяются физически, и очередь перестаёт быть единой «точкой плавления».

Качество данных: «грязные» события и миграции

События — это историческая правда. Но если их схема меняется каждые две недели, любая read‑модель будет сыпаться, пока вы догоняете новые поля.

Для этого делаем:

  1. Immutable events: новые поля — новый тип события (ProductPriceChangedV2).

  2. Upcasters: при чтении старых событий приводим к новой форме.

  3. Golden tests: фиксация payload‑ов в репозитории (snapshot‑тесты), чтобы случайно не сломать back‑compat.

Когда CQRS стоит того

Симптом

CQRS спасает?

90% запросов — чтение

Да

Нужно линейно масштабировать чтение

Да

Простой CRUD без отчётов

Нет

Транзакция должна обновить сразу 5 таблиц

Скорее нет

Домена сложная, бизнес‑инvariants тяжёлые

Часто да

Вывод

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

Полезно еще почитать материал по этой ссылке.


Как архитектор или тимлид, вы понимаете, насколько важно развивать команду. OTUS предлагает курсы для специалистов в ключевых IT-направлениях — от DevOps до программирования и аналитики. Это гибкие форматы обучения с акцентом на практику, которые легко интегрируются в рабочий процесс. Узнайте, как OTUS поможет повысить квалификацию вашей команды и улучшить IT-процессы в компании.

Комментарии (5)


  1. cyber_ded
    23.05.2025 12:33

    Что-то намудрили со статьей, DTO и мапперы не имеют прямого отношения к CQRS, это уже из DDD части. Если вы добавляете CQRS, все что добавляется в проект, это логическое разделение методов условного сервиса (без cqrs скорее всего вы бы использовали именно сервис хранящий бизнес-логику определенной части функционала) на отдельные независимые команды. Плюс эти команды разделяются на те которые мутируют данные (Cqrs) и те которые не мутируют а только отдают (cQrs). Да, каждая команда состоит из двух файлов, Самой Command/Query и handler, но первый обычно очень тонкий и по-сути сам по себе является этим самым "DTO" только для хендлера непосредственно. И это не тот DTO о котором в статье речь. И меняется способ вызова, вы теперь не вызываете сервис который тянет другой сервис, а тот следующий, а создаете Command/Query, заполняете данными (это можно и через конструктор сразу прокинуть все) и отправляете его в шину, которая его уже запустит сама.

    По итогу имеем:
    - строгое разделение команд/запросов, в том числе по файлам
    - проще разобраться в отдельном файле-команде, чем искать что-то в огромном сервисе
    - проще тестировать и масштабировать

    Как и с DDD в целом, нужно просто понимать что это и как это использовать, а не просто внедрять все подряд, и если внедрять именно cqrs - сложностей это практически не прибавит, чего нельзя сказать о DDD в целом.


  1. VanKrock
    23.05.2025 12:33

    Я в своей работе использую, но не совсем CQRS. У меня есть IRequest<> и IRequestHandler<,> но я не разделяю на read и write, вместо этого сами запросы могут быть с префиксами get, list и save (не create и update) например get_product, list_products, save_product. Маппинг использую только entity->dto, в save_product свойства маппятся вручную. Это позволяет избегать конфликты, потому что разработчики обычно работают над разными хендлерами, логика создания и обновления сущности в одном месте, при добавлении нового свойства в сущности это нужно поправить лишь в одном месте, по префиксу даже в сваггер итак понятно будет только чтение или будет какая-то запись в бд


  1. Dhwtj
    23.05.2025 12:33

    Так и не понял, зачем это вам если у вас модель чтения совпадает с моделью записи. Что чтения больше в разы? Пример не очень удачный тогда. Не обоснованный бизнес потребностью.

    Понятно, что этот арх паттерн даёт большую рассогласованность данных. Надо применять разные приёмы.

    Я часто применяю CQRS в аналитических системах где надо и разграничение нагрузки и денормализация и рассогласованность не страшна в пределах суток. Но вот тут прям странно.


    1. totsamiynixon
      23.05.2025 12:33

      Я бы сказал, что CQRS в чистом виде это узкоспециализированный подход для определенного ряда задач. А вот компоненты из которых состоит подход CQRS могут быть использованы по отдельности или вместе в различных комбинациях. Компоненты следующие: CQS, идемпотентность, EDA, saga / process manager и тд.

      Например в системах, которые строю я, чтение всегда консистетно. Если я добавил предмет в корзину, я смогу прочитать корзину по ID из модуля корзина и получить последнее состояние. А вот различные процессы вроде аналитики добавления товара в корзину, обновление рейтинга популярности продукта в каталоге, обновление доступности товаров в корзине в зависимости от наличия продукта на складе становятся eventually consistent. Но по факту это совершенно другие агрегаты, а агрегат корзина всегда консистентен. Так что это что-то вроде eventually consistent между агрегатами. И это то, как работает жизнь. Таким образом не нужно с этим бороться, нужно понять, как сделать так, чтобы это работало на вас.

      Я так же рассматриваю так называемые "модели на чтение" как отдельный вид агрегата. Это позволяет давать реальное бизнес определение такого рода сущностям. Обычно те самые ключевые индексы, которые необходимы для решения задач повышенного чтения, имеют свои бизнес концепции. Например "каталог продуктов". Бизнес понимает, что изменение агрегата "продукт" в админке не означает немедленного обновление в каталоге. Потому что необходимо обновить "срез каталога по категориям", "срез каталога по ценовому сегменту" и так далее.


  1. totsamiynixon
    23.05.2025 12:33

    В статье описываются приемы борьбы с ветряными мельницами. Вернёмся в доцифровые времена.

    Ситуация номер 1: Вот представьте, вы звоните вашему локальному букмекеру, чтобы сделать ставку на футбольный матч. Например, что игрок Х отдаст 100 точных пассов. Потом звоните ему, чтобы скорректировать ставку на 50 точных пассов, а он говорит, что не знает о такой ставке. Возможно ли это? Только если он потерял стикер с вашей ставкой, а не потому что некая виртуальная модель на чтение ещё не построена.

    Ситуация номер 2: Снова звонок букмекеру, ставка сделана. Матч закончен, вы тут же звоните букмекеру, чтобы узнать о выигрыше по ставке. А он говорит, что статус ставки все ещё "в обработке". Потому что аналитик записывал пасы по порядку их передач, а не по игроку. Поэтому ему нужно сложить все пасы игрока и посчитать из общее количество. Он может сделать это и держать результат в голове (in-memory), а может после расчета записать результат в какую-то сводку по матчу (постоянная память). Когда он закончит рассчет, он обойдет все ставки на эту позицию и потом отметит выигрышные.

    Таким образом нет никакой отдельной модели на чтение и на запись в техническом плане. Есть бизнес процесс, в котором не бывает мгновенной согласованности. Есть способ быстрой записи, чтобы в моменте успевать записывать статистику и потом способ трансформации этой информации в другой вид, удобный для закрытия ставок по позиции. Это все один процесс без каких-либо виртуальных концепций.

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