Що таке патерн 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 разів більше за записи або коли кожна сторона потребує іншої структури даних.
Швидкий приклад
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 для команд і запитів:
// Неправильно: один клас для обох напрямків
class OrderDto {
userId: string;
items: Item[];
total: number; // запит відображає, команда ще не обчислює
}
// Правильно: окремі структури для кожної сторони
class CreateOrderCommand { userId: string; items: Item[]; }
class OrderSummaryView { id: string; total: number; status: string; }Команди потребують полів для валідації, запити потребують полів для відображення. Один спільний клас зв'язує обидві сторони і знищує сенс патерну.
Забули опублікувати подію після команди:
// Неправильно: зберігає в БД, але read-сторона нічого не знає
async execute(command: CreateOrderCommand) {
await this.repo.save(order);
// відсутнє: this.eventBus.publish(new OrderCreatedEvent(order.id))
}Без події обробники запитів, що читають з Redis-проєкції, ніколи не дізнаються про запис. Саме через це з'являються «примарні дані»: запис є в основній БД, а запит повертає застаріле значення.
Використання in-memory шини в розподіленій системі:
// Неправильно для мікросервісів: команди залишаються в межах процесу
@Module({ imports: [CqrsModule] })
// Правильно: налаштувати розподілений транспортний шар
// Redis або RabbitMQ transporter для крос-сервісної відправкиСтандартний CqrsModule не перетинає межі процесів. Команди, надіслані в одному сервісі, ніколи не досягнуть обробників в іншому.
Відсутність валідації в обробниках команд:
// Неправильно: некоректні дані йдуть прямо в БД
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-партиції добре підходять для цього. Слідкуй за гарячими шардами при нерівномірному розподілі ключів.
Приклади
Базовий: обробник команди і обробник запиту
// 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-модель
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 повертає застарілі дані навіть після успішного запису.
Просунутий рівень: збірка модуля
// Контролер відправляє тільки через шини
@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.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.