Привет, Хабр!
Если вы тимлид или архитектор, и в команде всё чаще звучит «давай сделаем CQRS» — стоит остановиться. Этот паттерн мощный, но далеко не беспроблемный.
Зачем вообще вспоминать о CQRS, если есть Entity Framework и SaveChanges()?
Проблема — в нагрузке. Любой монолит когда‑нибудь упирается в диск/CPU/команду DevOps. CQRS предлагает разделить модели чтения и записи, оптимизируя их независимо. В теории ─ вроде ок:
Write‑модель заточена под транзакционную целостность, минимум индексов, максимум through‑put.
Read‑модель ломится от индексов, денормализации и кэширования.
Microsoft рисует это на своих красивых диаграммах уже много лет. Но цена вопроса — сложность.
Лишний оверхед для банального CRUD
У нас простая табличка Customers
, может просто сделать DbSet<Customer>
? В CQRS на один такой Customer
внезапно появляется:
Слой |
Что появляется |
---|---|
Команды |
|
Обработчики |
|
DTO |
|
Маппинг |
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‑модель ещё не проиндексировала событие.
Как воспроизвести баг локально
Создаём команду
AddToCart
.В
CartReadModel
слушаемProductAdded
и апдейтим denormalized view.Перед запуском параллельно стреляем 1000 команд.
Между
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.version
≥commandVersion
.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‑модель будет сыпаться, пока вы догоняете новые поля.
Для этого делаем:
Immutable events: новые поля — новый тип события (
ProductPriceChangedV2
).Upcasters: при чтении старых событий приводим к новой форме.
Golden tests: фиксация payload‑ов в репозитории (snapshot‑тесты), чтобы случайно не сломать back‑compat.
Когда CQRS стоит того
Симптом |
CQRS спасает? |
---|---|
90% запросов — чтение |
Да |
Нужно линейно масштабировать чтение |
Да |
Простой CRUD без отчётов |
Нет |
Транзакция должна обновить сразу 5 таблиц |
Скорее нет |
Домена сложная, бизнес‑инvariants тяжёлые |
Часто да |
Вывод
Применяйте паттерн осознанно, автоматизируйте миграции, отделяйте приватное от публичного, не бойтесь версионировать события — и вы избежите тех самых подводных камней.
Полезно еще почитать материал по этой ссылке.
Как архитектор или тимлид, вы понимаете, насколько важно развивать команду. OTUS предлагает курсы для специалистов в ключевых IT-направлениях — от DevOps до программирования и аналитики. Это гибкие форматы обучения с акцентом на практику, которые легко интегрируются в рабочий процесс. Узнайте, как OTUS поможет повысить квалификацию вашей команды и улучшить IT-процессы в компании.
Комментарии (2)
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 свойства маппятся вручную. Это позволяет избегать конфликты, потому что разработчики обычно работают над разными хендлерами, логика создания и обновления сущности в одном месте, при добавлении нового свойства в сущности это нужно поправить лишь в одном месте, по префиксу даже в сваггер итак понятно будет только чтение или будет какая-то запись в бд
cyber_ded
Что-то намудрили со статьей, DTO и мапперы не имеют прямого отношения к CQRS, это уже из DDD части. Если вы добавляете CQRS, все что добавляется в проект, это логическое разделение методов условного сервиса (без cqrs скорее всего вы бы использовали именно сервис хранящий бизнес-логику определенной части функционала) на отдельные независимые команды. Плюс эти команды разделяются на те которые мутируют данные (Cqrs) и те которые не мутируют а только отдают (cQrs). Да, каждая команда состоит из двух файлов, Самой Command/Query и handler, но первый обычно очень тонкий и по-сути сам по себе является этим самым "DTO" только для хендлера непосредственно. И это не тот DTO о котором в статье речь. И меняется способ вызова, вы теперь не вызываете сервис который тянет другой сервис, а тот следующий, а создаете Command/Query, заполняете данными (это можно и через конструктор сразу прокинуть все) и отправляете его в шину, которая его уже запустит сама.
По итогу имеем:
- строгое разделение команд/запросов, в том числе по файлам
- проще разобраться в отдельном файле-команде, чем искать что-то в огромном сервисе
- проще тестировать и масштабировать
Как и с DDD в целом, нужно просто понимать что это и как это использовать, а не просто внедрять все подряд, и если внедрять именно cqrs - сложностей это практически не прибавит, чего нельзя сказать о DDD в целом.