Skip to main content

Що таке патерн CQRS і як його реалізувати в NestJS?

CQRS (Command Query Responsibility Segregation) розділяє застосунок на два окремі шляхи: команди змінюють стан, запити тільки читають. NestJS постачає @nestjs/cqrs, щоб реалізувати цей патерн без написання шару маршрутизації вручну.

Теорія

TL;DR

  • Аналогія: кухня ресторану (команди) змінює стан страв, зала (запити) просто дізнається про меню. Обидві частини масштабуються незалежно.
  • Замість одного OrdersService з create() і findOne() отримуємо CreateOrderHandler, що пише в Postgres, і GetOrderHandler, що читає з Redis.
  • CommandBus.execute() запускає запис, QueryBus.execute() запускає читання.
  • NestJS зіставляє команди з обробниками через Reflect.metadata за посиланням на клас.
  • Використовуй, коли читань у 10 разів більше за записи або коли кожна сторона потребує іншої структури даних.

Швидкий приклад

typescript
import { CqrsModule, CommandBus, QueryBus } from '@nestjs/cqrs'; export class CreateOrderCommand { constructor(public readonly userId: string, public readonly items: Item[]) {} } export class GetOrderQuery { constructor(public readonly orderId: string) {} } @Controller('orders') export class OrdersController { constructor(private commandBus: CommandBus, private queryBus: QueryBus) {} @Post() create(@Body() dto: CreateOrderDto) { // Шлях запису: іде до CreateOrderHandler return this.commandBus.execute(new CreateOrderCommand(dto.userId, dto.items)); } @Get(':id') find(@Param('id') id: string) { // Шлях читання: іде до GetOrderHandler return this.queryBus.execute(new GetOrderQuery(id)); } }

Контролер знає тільки про шини, не про конкретні обробники. Шина знаходить потрібний обробник за класом, переданим у @CommandHandler() або @QueryHandler() при реєстрації.

Головна відмінність від звичайного CRUD

У стандартному NestJS-коді create() і findOne() живуть в одному сервісі, використовують один репозиторій і одну форму сутності. CQRS розділяє їх примусово: командна сторона перевіряє бізнес-правила і пише в нормалізовану таблицю, сторона запитів читає з денормалізованого кешу або окремої read-бази. Завдяки цьому поділу можна запустити 100 pod-ів для читання, не чіпаючи шлях запису.

Як @nestjs/cqrs працює всередині

CqrsModule реєструє CommandBus і QueryBus як синглтони в DI-контейнері NestJS. Коли викликається commandBus.execute(new CreateOrderCommand(...)), шина зчитує ім'я конструктора через Reflect.metadata, знаходить провайдер з декоратором @CommandHandler(CreateOrderCommand) і викликає його метод execute(). За замовчуванням все відбувається в пам'яті в межах одного процесу. Для мікросервісів потрібно замінити транспортний шар на Redis або RabbitMQ, щоб команди перетинали межі сервісів.

Коли використовувати CQRS

  • Читань у 10 разів більше за записи. Типовий приклад: списки товарів в e-commerce проти створення замовлень.
  • Кожна сторона потребує іншої структури даних. Командна сторона застосовує бізнес-інваріанти, сторона запитів повертає плоскі денормалізовані view для швидкого рендерингу.
  • Будується подієво-орієнтована архітектура, де команди публікують доменні події, а запити відновлюють стан з проєкцій.
  • Потрібне незалежне масштабування: один pod для транзакцій, багато pod-ів для кешованих відповідей.

Не використовуй для прототипів, внутрішніх інструментів і застосунків без серйозного навантаження. Шаблонний код для обробників, шин і подій потроює кількість файлів без жодної переваги на малому трафіку.

Таблиця порівняння

АспектМонолітний CRUDCQRS
МоделіОдна сутність для читання і записуОкремі command DTO і query view
База данихОдна реляційна БДЗапис у Postgres, читання з Redis або Mongo
МасштабуванняТільки read-репліки1 pod для запису, 100 pod-ів для читання
СкладністьНизькаВисока: обробники, шини, eventual consistency
Коли використовуватиПрототипи, малонавантажені застосункиСистеми з сильним дисбалансом читання/запису

Типові помилки

Одне DTO для команд і запитів:

typescript
// Неправильно: один клас для обох напрямків class OrderDto { userId: string; items: Item[]; total: number; // запит відображає, команда ще не обчислює } // Правильно: окремі структури для кожної сторони class CreateOrderCommand { userId: string; items: Item[]; } class OrderSummaryView { id: string; total: number; status: string; }

Команди потребують полів для валідації, запити потребують полів для відображення. Один спільний клас зв'язує обидві сторони і знищує сенс патерну.

Забули опублікувати подію після команди:

typescript
// Неправильно: зберігає в БД, але read-сторона нічого не знає async execute(command: CreateOrderCommand) { await this.repo.save(order); // відсутнє: this.eventBus.publish(new OrderCreatedEvent(order.id)) }

Без події обробники запитів, що читають з Redis-проєкції, ніколи не дізнаються про запис. Саме через це з'являються «примарні дані»: запис є в основній БД, а запит повертає застаріле значення.

Використання in-memory шини в розподіленій системі:

typescript
// Неправильно для мікросервісів: команди залишаються в межах процесу @Module({ imports: [CqrsModule] }) // Правильно: налаштувати розподілений транспортний шар // Redis або RabbitMQ transporter для крос-сервісної відправки

Стандартний CqrsModule не перетинає межі процесів. Команди, надіслані в одному сервісі, ніколи не досягнуть обробників в іншому.

Відсутність валідації в обробниках команд:

typescript
// Неправильно: некоректні дані йдуть прямо в БД async execute(command: CreateOrderCommand) { await this.repo.save(command); } // Правильно: спочатку перевіряємо async execute(command: CreateOrderCommand) { if (!command.userId) throw new BadRequestException('userId required'); const order = new Order(command.userId, command.items); await this.repo.save(order); }

Де це використовується

  • MedusaJS: команди пишуть у Postgres таблицю замовлень, запити читають з Elasticsearch-індексу товарів.
  • EventStoreDB + NestJS: команди додають події до сховища, запити будують read-моделі з проєкцій.
  • Системи типу Uber: створення поїздки як команда в Kafka, запити статусу з Cassandra-проєкцій.
  • Адмін-панелі зі складними сценаріями: окремі саги (sagas) обробляють багатокрокові операції, не блокуючи read-обробники.

Follow-up питання

Q: У чому різниця між CQS і CQRS?
A: CQS (Command Query Separation) - це правило рівня методу: функція або змінює стан, або повертає дані, але не одночасно. CQRS - архітектурний патерн: окремі моделі, обробники та потенційно окремі бази даних для кожної сторони.

Q: Як обробляти eventual consistency між командною стороною і стороною запитів?
A: Обробники команд публікують доменні події після запису. Обробники подій через @EventsHandler() оновлюють read-моделі в Redis або Mongo. Затримка між записом і оновленням проєкції зазвичай у межах мілісекунд, але UI треба проєктувати з урахуванням цього.

Q: Коли CQRS шкодить більше, ніж допомагає?
A: У застосунках з менш ніж 10k запитів на день або для команд, незнайомих з domain-driven design. Шаблонний код множиться без жодної переваги масштабування. Починай з простих сервісів і переходь на CQRS тільки коли патерни читання і запису явно розходяться.

Q: Як робити запити між bounded context-ами?
A: Не використовувати join-и між контекстами. Використовуй доменні події, щоб проєктувати дані в денормалізоване сховище запитів, яке належить контексту, що запитує. Прямі join-и зв'язують контексти і руйнують архітектуру.

Q (senior): У шардованій системі як маршрутизувати команди до потрібного шарда?
A: Додай ключ шардування, наприклад tenantId або userId, у метадані команди. Транспортний шар шини використовує consistent hashing для маршрутизації. Kafka-партиції добре підходять для цього. Слідкуй за гарячими шардами при нерівномірному розподілі ключів.

Приклади

Базовий: обробник команди і обробник запиту

typescript
// npm install @nestjs/cqrs // Команда: несе намір написати щось export class CreateOrderCommand { constructor( public readonly userId: string, public readonly items: { productId: string; quantity: number }[], ) {} } // Обробник команди: валідує, пише в БД, публікує подію @CommandHandler(CreateOrderCommand) export class CreateOrderHandler implements ICommandHandler<CreateOrderCommand> { constructor( private readonly repo: OrderRepository, private readonly eventBus: EventBus, ) {} async execute(command: CreateOrderCommand) { if (!command.userId) throw new BadRequestException('userId required'); const order = await this.repo.save({ userId: command.userId, items: command.items, status: 'pending', }); this.eventBus.publish(new OrderCreatedEvent(order.id, order.userId)); return order; } } // Запит: несе намір прочитати щось export class GetOrderByIdQuery { constructor(public readonly orderId: string) {} } // Обробник запиту: читає з read-репозиторію (Redis, read-репліка тощо) @QueryHandler(GetOrderByIdQuery) export class GetOrderByIdHandler implements IQueryHandler<GetOrderByIdQuery> { constructor(private readonly readRepo: OrderReadRepository) {} async execute(query: GetOrderByIdQuery) { return this.readRepo.findById(query.orderId); } }

Обробник команди пише в основну БД і публікує подію. Обробник запиту читає з окремого репозиторію. Між ними нічого спільного, крім події, що їх пов'язує.

Середній рівень: обробник події оновлює read-модель

typescript
export class OrderCreatedEvent { constructor( public readonly orderId: string, public readonly userId: string, ) {} } // Обробник події: тримає Redis-проєкцію актуальною після кожного запису @EventsHandler(OrderCreatedEvent) export class OrderCreatedHandler implements IEventHandler<OrderCreatedEvent> { constructor( private readonly redisService: RedisService, private readonly repo: OrderReadRepository, ) {} async handle(event: OrderCreatedEvent) { const order = await this.repo.findById(event.orderId); const key = `user:${event.userId}:orders`; const existing = JSON.parse(await this.redisService.get(key) || '[]'); await this.redisService.set( key, JSON.stringify([...existing, { id: order.id, status: order.status }]), ); } } // Обробник запиту читає з Redis замість Postgres @QueryHandler(GetUserOrdersQuery) export class GetUserOrdersHandler implements IQueryHandler<GetUserOrdersQuery> { constructor(private readonly redisService: RedisService) {} async execute(query: GetUserOrdersQuery) { const data = await this.redisService.get(`user:${query.userId}:orders`); return data ? JSON.parse(data) : []; } }

Подія - це міст між двома сторонами. Без неї GetUserOrdersQuery повертає застарілі дані навіть після успішного запису.

Просунутий рівень: збірка модуля

typescript
// Контролер відправляє тільки через шини @Controller('orders') export class OrdersController { constructor( private readonly commandBus: CommandBus, private readonly queryBus: QueryBus, ) {} @Post() async create(@Body() dto: CreateOrderDto, @CurrentUser() user: User) { return this.commandBus.execute(new CreateOrderCommand(user.id, dto.items)); } @Get(':id') async findOne(@Param('id') id: string) { return this.queryBus.execute(new GetOrderByIdQuery(id)); } @Get('user/me') async myOrders(@CurrentUser() user: User) { return this.queryBus.execute(new GetUserOrdersQuery(user.id)); } } // Кожен обробник має бути в providers, інакше шина кидає помилку в runtime const CommandHandlers = [CreateOrderHandler, CancelOrderHandler]; const QueryHandlers = [GetOrderByIdHandler, GetUserOrdersHandler]; const EventHandlers = [OrderCreatedHandler, OrderCancelledHandler]; @Module({ imports: [CqrsModule], controllers: [OrdersController], providers: [ ...CommandHandlers, ...QueryHandlers, ...EventHandlers, OrderRepository, OrderReadRepository, ], }) export class OrdersModule {}

Якщо обробник відсутній у providers, шина кидає "no handler found for [ClassName]" при першому виклику. Це найпоширеніша runtime-помилка при першому налаштуванні @nestjs/cqrs.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?