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