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