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

Если вы тимлид или архитектор, и в команде всё чаще звучит «давай сделаем 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-процессы в компании.

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


  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 свойства маппятся вручную. Это позволяет избегать конфликты, потому что разработчики обычно работают над разными хендлерами, логика создания и обновления сущности в одном месте, при добавлении нового свойства в сущности это нужно поправить лишь в одном месте, по префиксу даже в сваггер итак понятно будет только чтение или будет какая-то запись в бд