Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Що таке патерн CQRS і як його реалізувати в NestJS?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**CQRS (Command Query Responsibility Segregation)** розділяє операції запису (команди) і читання (запити), щоб кожна сторона могла використовувати різні моделі та бази даних. NestJS реалізує це через `@nestjs/cqrs` з `CommandBus`, `QueryBus` і окремими класами обробників. ```typescript this.commandBus.execute(new CreateOrderCommand(userId, items)); // пише в БД this.queryBus.execute(new GetOrderQuery(orderId)); // читає з кешу ``` **Головне:** команди публікують доменні події після запису; запити читають з проєкцій, побудованих на основі цих подій.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**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-ів для кешованих відповідей. Не використовуй для прототипів, внутрішніх інструментів і застосунків без серйозного навантаження. Шаблонний код для обробників, шин і подій потроює кількість файлів без жодної переваги на малому трафіку. ### Таблиця порівняння | Аспект | Монолітний CRUD | CQRS | |---|---|---| | Моделі | Одна сутність для читання і запису | Окремі 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`.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.