28 июл. 2026 г.

Как я использую DDD и CQRS в backend-проектах

DDD и CQRS полезны не как набор папок, а как способ удержать бизнес-логику в явных сценариях: командах, правилах, состояниях и событиях.

DDD и CQRS легко превратить в архитектурный театр: много папок, длинные названия классов, несколько слоев ради нескольких CRUD-операций. Я стараюсь использовать эти подходы иначе. Для меня это не про красоту структуры, а про контроль над бизнес-логикой.

Если проект небольшой и в нем есть только “создать, обновить, удалить”, часто достаточно обычного service layer. Но как только появляются статусы, роли, события, документы, аукционы, споры, подписания или фоновые процессы, простая структура начинает течь. Правила оказываются в контроллерах, guards, DTO, ORM-сущностях и случайных helper'ах.

DDD и CQRS помогают не дать этому хаосу стать нормой.

Я начинаю не с папок

Перед тем как выбирать структуру, я пытаюсь понять домен:

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

Если на эти вопросы нет ответов, CQRS не спасет. Он просто красиво разложит неопределенность по директориям.

CQRS как язык намерений

Мне нравится CQRS тем, что команда называет действие. UpdateOrderDto говорит мало. AcceptOrderResponseCommand, StartDisputeCommand, FinishAuctionRoundCommand или ModerateCommentCommand говорят гораздо больше.

Handler в таком подходе становится местом, где собирается сценарий:

await this.permissions.assertCanAcceptResponse(user, order);
order.acceptResponse(responseId);
await this.orders.save(order);
await this.events.publish(new OrderResponseAcceptedEvent(order.id));

Это не обязательно должен быть идеальный “чистый” DDD. Важно другое: по коду видно, что происходит в продукте. Не “обновили строку”, а “приняли отклик”, “завершили раунд”, “отправили документ на подпись”.

Запросы при этом живут отдельно. Read-side может быть оптимизирован под таблицу админки, GraphQL resolver, публичную страницу или фильтры. Я не пытаюсь заставить write-model быть удобной для всех вариантов чтения.

DDD без религии

Я не считаю, что каждый проект обязан иметь aggregates, value objects и domain events с первого дня. Иногда users.service.ts и нормальная валидация - это честное и достаточное решение.

Но есть признаки, что доменная модель уже нужна:

  • у сущности есть жизненный цикл;
  • действие зависит от роли пользователя и состояния сущности;
  • после команды должны появиться побочные эффекты;
  • есть несколько клиентов: web, mobile, bot, admin;
  • нужно объяснять ошибки человеческим языком;
  • “просто update” начинает ломать соседние сценарии.

Хороший доменный код можно читать без знания Fastify, GraphQL, TypeORM или конкретного UI. Правило “исполнитель не может завершить заказ, если команда не назначена” должно жить как правило продукта, а не как if внутри resolver'а.

Где я провожу границы

Обычно мне хватает такой схемы:

  • controller/resolver принимает запрос и валидирует DTO;
  • command/query handler координирует сценарий;
  • domain service или entity содержит правило;
  • repository прячет способ хранения;
  • event/job выносит побочные эффекты из основного запроса.

Я не пытаюсь добиться абсолютной чистоты. Если правило простое, оно может жить в handler. Если оно начинает повторяться или становится важной частью домена, я выношу его ближе к entity/domain service.

Архитектура должна помогать двигаться, а не заставлять платить налог за каждый новый endpoint.

События и очереди

В продуктовых backend'ах многие действия не заканчиваются сохранением в базу. После принятия отклика нужно отправить уведомления. После загрузки документа - запустить конвертацию. После завершения аукциона - обновить участников и отправить real-time событие.

Я стараюсь не делать все это внутри одного синхронного запроса. Команда должна выполнить главный сценарий и зафиксировать состояние. Остальное можно отправить в событие или очередь:

await this.commandBus.execute(new FinishAuctionRoundCommand(auctionId));
await this.queue.add('notify-auction-participants', { auctionId });

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

Практический вывод

DDD и CQRS нужны не для того, чтобы проект выглядел серьезнее. Они нужны, когда бизнес-логика становится ценнее инфраструктурного кода.

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

Комментарии

Пока нет комментариев — будьте первым.