Пошук уроків, статей та іншого контенту
Розділите команди й запити за допомогою CommandBus, QueryBus, обробників і моделей читання та запису.
CQRS (Command Query Responsibility Segregation) — це підхід, за якого операції зміни даних і операції читання даних розділяються:
Command описує намір змінити стан системи.
Command handler виконує команду.
Query описує запит на отримання даних.
Query handler читає дані та формує результат.
CommandBus знаходить і запускає обробник команди.
QueryBus знаходить і запускає обробник запиту.
У звичайному сервісі NestJS логіка часто виглядає так:
@Injectable()
export class TasksService {
create(title: string) {
// запис у базу даних
}
findAll() {
// читання з бази даних
}
}У CQRS ці операції розділені на окремі класи. Завдяки цьому:
логіка запису не змішується з логікою читання;
запити можна оптимізувати окремо від команд;
для читання та запису можна використовувати різні моделі;
складні сценарії зміни стану легше тестувати;
обробники стають меншими та мають одну відповідальність.
CQRS не вимагає обов’язково використовувати дві бази даних. На початку обидві моделі можуть працювати поверх однієї бази, але мати різні класи та різні сценарії використання.
Для роботи з CQRS використовується пакет @nestjs/cqrs.
npm install @nestjs/cqrsУ модулі потрібно імпортувати CqrsModule:
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
@Module({
imports: [CqrsModule],
})
export class TasksModule {}CqrsModule реєструє CommandBus, QueryBus та механізм пошуку обробників за допомогою декораторів.
Команда — це об’єкт, який містить дані для виконання операції зміни стану.
Назва команди зазвичай має форму дієслова:
CreateTaskCommand;
CompleteTaskCommand;
DeleteTaskCommand;
ChangeUserEmailCommand.
Команда не повинна сама виконувати бізнес-логіку. Вона лише переносить дані до обробника.
export class CreateTaskCommand {
constructor(public readonly title: string) {}
}Команду можна запустити через CommandBus:
const result = await this.commandBus.execute(
new CreateTaskCommand('Підготувати звіт'),
);CommandBus знаходить обробник, позначений декоратором @CommandHandler(CreateTaskCommand), і передає йому команду.
Обробник команди реалізує інтерфейс ICommandHandler.
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
@CommandHandler(CreateTaskCommand)
export class CreateTaskHandler
implements ICommandHandler<CreateTaskCommand>
{
async execute(command: CreateTaskCommand) {
// бізнес-логіка команди
}
}Метод execute() отримує екземпляр команди. Саме тут розміщується сценарій зміни стану:
перевірка бізнес-правил;
створення або зміна запису;
збереження через write-модель;
повернення результату операції.
Обробник можна типізувати типом результату:
export interface CreatedTask {
id: string;
}
@CommandHandler(CreateTaskCommand)
export class CreateTaskHandler
implements ICommandHandler<CreateTaskCommand, CreatedTask>
{
async execute(command: CreateTaskCommand): Promise<CreatedTask> {
return {
id: 'generated-id',
};
}
}Команди краще робити незмінними: їхні поля оголошують із модифікатором readonly. Це зменшує ризик випадкової зміни вхідних даних під час виконання.
Запит не повинен змінювати стан системи. Він лише повертає дані.
export class GetTasksQuery {
constructor(public readonly completed?: boolean) {}
}Назви запитів зазвичай мають форму:
GetTaskQuery;
GetTasksQuery;
SearchTasksQuery;
GetTaskStatisticsQuery.
Запит також може містити параметри фільтрації, сортування або пагінації.
Обробник запиту реалізує IQueryHandler і позначається декоратором @QueryHandler.
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
@QueryHandler(GetTasksQuery)
export class GetTasksHandler
implements IQueryHandler<GetTasksQuery>
{
async execute(query: GetTasksQuery) {
// читання з read-моделі
}
}На відміну від обробника команди, обробник запиту не повинен виконувати побічні зміни:
не створювати записи;
не змінювати статуси;
не запускати операції, які змінюють бізнес-стан.
Його завдання — отримати дані у форматі, зручному для конкретного сценарію читання.
У CQRS моделі запису й читання мають різні призначення.
Write-модель оптимізована для зміни стану та перевірки бізнес-правил. Вона може містити:
повну бізнес-сутність;
службові поля;
інваріанти;
методи зміни стану;
зв’язки, необхідні саме для запису.
Read-модель оптимізована для отримання готового результату. Вона може:
містити денормалізовані дані;
об’єднувати інформацію з кількох сутностей;
мати структуру, зручну для конкретного екрана або API;
не містити методів зміни стану.
Наприклад, write-модель завдання може містити лише ідентифікатор, назву та статус, а read-модель — уже готове поле statusLabel, відформатовану дату й ім’я автора.
Розділення моделей не означає, що їх завжди потрібно зберігати в різних базах. Фізичне розділення можна додати пізніше, якщо цього вимагатимуть навантаження або структура системи.
Нижче наведено приклад модуля завдань. Він використовує окремі write- та read-репозиторії. Для простоти дані зберігаються в пам’яті, але структура обробників залишається такою самою, як і під час роботи з реальною базою даних.
// src/tasks/application/commands/create-task.command.ts
export class CreateTaskCommand {
constructor(public readonly title: string) {}
}// src/tasks/application/queries/get-tasks.query.ts
export class GetTasksQuery {
constructor(public readonly completed?: boolean) {}
}// src/tasks/infrastructure/task-repositories.ts
import { Injectable } from '@nestjs/common';
export interface WriteTask {
id: string;
title: string;
completed: boolean;
}
export interface ReadTask {
id: string;
title: string;
status: 'open' | 'completed';
}
@Injectable()
export class TaskWriteRepository {
private readonly tasks = new Map<string, WriteTask>();
save(task: WriteTask): void {
this.tasks.set(task.id, task);
}
findById(id: string): WriteTask | undefined {
return this.tasks.get(id);
}
}
@Injectable()
export class TaskReadRepository {
private readonly tasks = new Map<string, ReadTask>();
upsert(task: ReadTask): void {
this.tasks.set(task.id, task);
}
findAll(completed?: boolean): ReadTask[] {
const tasks = [...this.tasks.values()];
if (completed === undefined) {
return tasks;
}
const expectedStatus = completed ? 'completed' : 'open';
return tasks.filter((task) => task.status === expectedStatus);
}
}У реальному застосунку методи репозиторіїв працюватимуть із базою даних. Важливо, що обробники команд і запитів не залежать від деталей зберігання.
// src/tasks/application/commands/create-task.handler.ts
import { randomUUID } from 'node:crypto';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CreateTaskCommand } from './create-task.command';
import {
TaskReadRepository,
TaskWriteRepository,
} from '../../infrastructure/task-repositories';
export interface CreateTaskResult {
id: string;
}
@CommandHandler(CreateTaskCommand)
export class CreateTaskHandler
implements ICommandHandler<CreateTaskCommand, CreateTaskResult>
{
constructor(
private readonly writeRepository: TaskWriteRepository,
private readonly readRepository: TaskReadRepository,
) {}
async execute(
command: CreateTaskCommand,
): Promise<CreateTaskResult> {
const title = command.title.trim();
if (title.length === 0) {
throw new Error('Назва завдання не може бути порожньою');
}
const task = {
id: randomUUID(),
title,
completed: false,
};
// Запис у write-модель.
this.writeRepository.save(task);
// Оновлення read-моделі для синхронного прикладу.
this.readRepository.upsert({
id: task.id,
title: task.title,
status: task.completed ? 'completed' : 'open',
});
return {
id: task.id,
};
}
}У цьому прикладі read-модель оновлюється безпосередньо під час виконання команди. Це простий варіант для демонстрації.
У складніших системах write-модель може публікувати подію, а окремий обробник події — оновлювати read-модель. У такому випадку між записом і доступністю даних для читання може виникати коротка затримка.
// src/tasks/application/queries/get-tasks.handler.ts
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { GetTasksQuery } from './get-tasks.query';
import {
ReadTask,
TaskReadRepository,
} from '../../infrastructure/task-repositories';
@QueryHandler(GetTasksQuery)
export class GetTasksHandler
implements IQueryHandler<GetTasksQuery, ReadTask[]>
{
constructor(
private readonly readRepository: TaskReadRepository,
) {}
async execute(query: GetTasksQuery): Promise<ReadTask[]> {
return this.readRepository.findAll(query.completed);
}
}Обробник запиту працює тільки з read-моделлю. Йому не потрібно знати, як створюється завдання або які правила застосовуються під час запису.
// src/tasks/tasks.controller.ts
import {
Body,
Controller,
Get,
Post,
Query,
} from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { CreateTaskCommand } from './application/commands/create-task.command';
import { GetTasksQuery } from './application/queries/get-tasks.query';
interface CreateTaskBody {
title: string;
}
@Controller('tasks')
export class TasksController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@Post()
async create(@Body() body: CreateTaskBody) {
return this.commandBus.execute(
new CreateTaskCommand(body.title),
);
}
@Get()
async findAll(@Query('completed') completed?: string) {
const completedFilter =
completed === undefined
? undefined
: completed === 'true';
return this.queryBus.execute(
new GetTasksQuery(completedFilter),
);
}
}Контролер тепер не містить бізнес-логіки:
POST /tasks створює команду;
GET /tasks створює запит;
відповідний bus знаходить потрібний обробник.
// src/tasks/tasks.module.ts
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { TasksController } from './tasks.controller';
import { CreateTaskHandler } from './application/commands/create-task.handler';
import { GetTasksHandler } from './application/queries/get-tasks.handler';
import {
TaskReadRepository,
TaskWriteRepository,
} from './infrastructure/task-repositories';
@Module({
imports: [CqrsModule],
controllers: [TasksController],
providers: [
TaskWriteRepository,
TaskReadRepository,
CreateTaskHandler,
GetTasksHandler,
],
})
export class TasksModule {}Обробники потрібно додати до providers. NestJS використовує їхні декоратори, щоб зареєструвати зв’язок між командами, запитами та обробниками.
Найчастіше CommandBus і QueryBus використовуються в контролерах, але їх також можна інжектити в інші application-сервіси.
import { Injectable } from '@nestjs/common';
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import { CreateTaskCommand } from './commands/create-task.command';
import { GetTasksQuery } from './queries/get-tasks.query';
@Injectable()
export class TasksApplicationService {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
createTask(title: string) {
return this.commandBus.execute(
new CreateTaskCommand(title),
);
}
getOpenTasks() {
return this.queryBus.execute(
new GetTasksQuery(false),
);
}
}Проте не варто створювати додатковий сервіс лише для того, щоб механічно делегувати кожен виклик до bus. Якщо контролер уже є тонким, прямий виклик CommandBus або QueryBus може бути зрозумілішим.
Для команди CreateTaskCommand потік виглядає так:
HTTP-контролер отримує вхідні дані.
Контролер створює CreateTaskCommand.
CommandBus знаходить CreateTaskHandler.
Обробник перевіряє бізнес-правила.
Обробник змінює write-модель.
Read-модель оновлюється безпосередньо або через механізм проєкції.
Контролер повертає результат.
Для запиту GetTasksQuery:
HTTP-контролер отримує параметри фільтрації.
Контролер створює GetTasksQuery.
QueryBus знаходить GetTasksHandler.
Обробник читає read-модель.
Обробник повертає готовий результат.
Контролер передає результат клієнту.
CQRS особливо корисний, коли:
операції читання та запису мають різну складність;
read-модель істотно відрізняється від write-моделі;
багато різних сценаріїв змінюють один і той самий домен;
потрібні окремі оптимізації для читання та запису;
система має складні бізнес-правила;
частини системи потрібно масштабувати незалежно.
Для простого CRUD-модуля CQRS може додати зайву кількість класів. Якщо операція зводиться до простого create, find, update і delete, звичайний сервіс часто буде достатнім.
CQRS — це не вимога створювати окремий клас для кожного рядка коду. Його варто застосовувати там, де розділення відповідальностей справді зменшує складність.
Помилки бізнес-правил повинні виникати в обробнику команди або в доменній моделі, а не в контролері.
import { BadRequestException } from '@nestjs/common';
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
@CommandHandler(CreateTaskCommand)
export class CreateTaskHandler
implements ICommandHandler<CreateTaskCommand>
{
async execute(command: CreateTaskCommand): Promise<void> {
const title = command.title.trim();
if (title.length < 3) {
throw new BadRequestException(
'Назва завдання має містити щонайменше 3 символи',
);
}
// Збереження завдання.
}
}Виняток буде перехоплений стандартним механізмом NestJS і перетворений на HTTP-відповідь, якщо команда викликається з контролера.
Водночас валідацію формату HTTP-вхідних даних можна залишати на рівні DTO та пайпів. Обробник має відповідати за бізнес-правила, а не за особливості конкретного транспорту.
Команда не повинна повертати великий набір даних лише тому, що ці дані вже доступні в обробнику.
Краще:
команда повертає ідентифікатор або короткий результат операції;
для отримання повного представлення виконується окремий запит.
Якщо система використовує read-модель, обробники запитів повинні читати саме її. Інакше розділення відповідальностей стає формальним.
Контролер не повинен вирішувати:
чи можна створити сутність;
як змінюється її стан;
які репозиторії викликати;
як синхронізувати моделі.
Контролер має створити команду або запит і передати його до відповідного bus.
Якщо обробник не доданий до providers, NestJS не зможе зареєструвати його в bus. У результаті під час виконання команди або запиту з’явиться помилка про відсутній handler.
Запит має бути операцією читання. Не слід оновлювати лічильники, змінювати статуси або записувати результати аудиту без чіткої причини. Такі дії порушують очікування від query.
CQRS додає класи команд, запитів, обробників і моделей. Для невеликого модуля це може зробити код складнішим, а не простішим.
Перед застосуванням CQRS потрібно оцінити, чи справді читання та запис мають різні вимоги.
Якщо read-модель оновлюється асинхронно, вона може деякий час містити старі дані. Це називається зрештою узгодженою моделлю.
Потрібно заздалегідь визначити:
чи прийнятна затримка для конкретного запиту;
як повторювати невдалі оновлення проєкції;
як відновлювати read-модель;
що повертати клієнту одразу після команди.
CQRS розділяє операції зміни стану та читання.
Команди запускаються через CommandBus.
Запити запускаються через QueryBus.
@CommandHandler пов’язує команду з обробником.
@QueryHandler пов’язує запит з обробником.
Write-модель оптимізована для бізнес-правил і запису.
Read-модель оптимізована для отримання готових даних.
CQRS не вимагає двох баз даних і може впроваджуватися поступово.
Обробники потрібно зареєструвати в providers модуля.
Для простого CRUD CQRS може бути зайвим, але для складних сценаріїв допомагає ізолювати відповідальності та оптимізувати систему.