NestJSにおけるCQRSパターン完全ガイド:スケーラブルなアーキテクチャを構築する
複雑なビジネスロジックや高いパフォーマンスが求められるバックエンドシステムを開発する際、従来のCRUDパターンでは限界が見えることがあります。このとき、CQRS(Command Query Responsibility Segregation)パターンが解決策となり得ます。本ガイドでは、NestJSでCQRSパターンを実装する方法をステップバイステップで解説し、実際のプロジェクトに適用可能な実践的ノウハウを共有します。
CQRSパターンの基本概念からNestJSでの実装、テスト戦略までを総合的に扱い、アプリケーションアーキテクチャを一段階進化させる手助けをします。
CQRSパターンとは? 従来のCRUDとの違い
CQRS(Command Query Responsibility Segregation)は、コマンド(Command)とクエリ(Query)の責任を分離するアーキテクチャパターンです。このパターンでは、読み取り操作と書き込み操作を完全に別のモデルとして分離し、それぞれを独立して最適化することが可能になります。
従来のCRUD方式の限界
従来のCRUDアーキテクチャでは、1つのサービスで全ての操作を処理します:
@Injectable()
export class UserService {
createUser(userData: CreateUserDto) { /* ... */ }
getUser(id: string) { /* ... */ }
updateUser(id: string, userData: UpdateUserDto) { /* ... */ }
deleteUser(id: string) { /* ... */ }
}
このような構成は小規模なプロジェクトでは問題ありませんが、ビジネスロジックが複雑になると次のような課題が発生します:
- ビジネスロジックの混在:読み取りと書き込みのロジックが同じサービスに混在
- スケーラビリティの制約:読み取りと書き込みで性能要件が異なるのに一括で処理されている
- テストの複雑さ:全ての機能が1つのクラスにあり、単体テストが困難
CQRSが提供する解決策
CQRSパターンはこれらの問題を以下のように解決します:
- 責任の分離:状態変更操作(Command)とデータ取得操作(Query)を明確に分離
- 独立したスケーリング:読み取りと書き込み処理をそれぞれ最適化し、個別にスケーリング可能
- 明確なフロー:何をすべきかが明確にわかる
- テストの容易さ:各ハンドラが独立しており、単体テストが簡単になる
NestJS CQRSモジュールの設定
NestJSでCQRSパターンを実装するには、@nestjs/cqrsパッケージを使用します。このパッケージは、CommandBus、QueryBus、EventBusなど、CQRS実装に必要な主要コンポーネントを提供します。
プロジェクトの設定
まず必要なパッケージをインストールします:
npm install --save @nestjs/cqrs
次に、ルートモジュールにCqrsModuleを登録します:
// app.module.ts
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs';
import { UserModule } from './user/user.module';
@Module({
imports: [CqrsModule.forRoot(),UserModule,
],
})
export class AppModule {}
CqrsModuleはCommandBus、QueryBus、EventBusを自動的に注入し、CQRSパターンの基盤を提供します。
CommandとQueryの実装
CQRSの中心は、CommandとQueryを明確に分離することです。
- Command → 状態を変更する処理(Create、Update、Deleteなど)
- Query → データを読み取る処理(Read)
それぞれの例を見てみましょう。
// commands/create-user.command.ts
export class CreateUserCommand {
constructor(public readonly name: string,public readonly email: string,
) {}
}
// queries/get-user.query.ts
export class GetUserQuery {
constructor(public readonly id: string) {}
}
Command & Query Handlerの実装
ハンドラは実際のロジックを処理する重要な部分です。
// commands/handlers/create-user.handler.ts
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CreateUserCommand } from '../create-user.command';
import { UserService } from '../../user.service';
@CommandHandler(CreateUserCommand)
export class CreateUserHandler implements ICommandHandler<CreateUserCommand> {
constructor(private readonly userService: UserService) {}
async execute(command: CreateUserCommand): Promise<any> {const { name, email } = command;return this.userService.create({ name, email });
}
}
// queries/handlers/get-user.handler.ts
import { QueryHandler, IQueryHandler } from '@nestjs/cqrs';
import { GetUserQuery } from '../get-user.query';
import { UserService } from '../../user.service';
@QueryHandler(GetUserQuery)
export class GetUserHandler implements IQueryHandler<GetUserQuery> {
constructor(private readonly userService: UserService) {}
async execute(query: GetUserQuery): Promise<any> {return this.userService.findById(query.id);
}
}
ハンドラは単一責任原則(SRP)に従い、それぞれのCommandまたはQueryのみに責任を持つべきです。
ControllerでのCQRSの適用
Controllerは、CommandBusおよびQueryBusを通じて各ハンドラへリクエストを委任します。
@Controller('users')
export class UserController {
constructor(private readonly commandBus: CommandBus,private readonly queryBus: QueryBus,
) {}
@Post()
async createUser(@Body() userData: { name: string; email: string }) {return this.commandBus.execute( new CreateUserCommand(userData.name, userData.email));
}
@Get(':id')
async getUser(@Param('id') id: string) {return this.queryBus.execute(new GetUserQuery(id));
}
}
ControllerはCQRS環境で「入力ポート」の役割のみを担い、ロジックはすべてハンドラに委任されます。
Event HandlingとSagas
CQRSの真の力は、イベントシステムとの組み合わせによって発揮されます。
// events/user-created.event.ts
export class UserCreatedEvent {
constructor(public readonly userId: string,public readonly email: string,
) {}
}
// events/handlers/user-created.handler.ts
@EventsHandler(UserCreatedEvent)
export class UserCreatedHandler implements IEventHandler<UserCreatedEvent> {
handle(event: UserCreatedEvent) {console.log(`ユーザーが作成されました: ${event.userId}`);
}
}
EventはCommand/Queryの実行結果として発生し、システムの他の部分がそれに反応することができます。
CQRS実装のテスト
CQRSでは、ハンドラ単位でのテストが非常に簡単です。
describe('CreateUserHandler', () => {
it('userService.createを正しく呼び出すべき', async () => {const command = new CreateUserCommand('Alice', 'alice@email.com');const result = await handler.execute(command);expect(result).toBeDefined();
});
});
各ハンドラは独立したユニットであるため、外部依存をモックして簡単にテスト可能です。
CQRSベストプラクティス
- CommandとQueryは必ず分離すること
- EventBusを積極的に活用して非同期フローを構築
- ハンドラ単位でのテストを習慣化すること
- ハンドラ内でのビジネスロジックを最小限に:サービス層へ委譲
CQRSは小規模なプロジェクトには過剰な場合があります。複雑なビジネスロジック、イベント駆動アーキテクチャ、大規模トラフィック環境において導入が適しています。