Battle-skills nestjs-cqrs-module
Architecture guide for NestJS modules with CQRS + Event Sourcing. Use when creating modules, adding queries/commands/sagas, or scaffolding. Triggers on \"new module\", \"create module\", \"CQRS pattern\".
git clone https://github.com/QuocTang/battle-skills
T=$(mktemp -d) && git clone --depth=1 https://github.com/QuocTang/battle-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/nestjs-cqrs-module" ~/.claude/skills/quoctang-battle-skills-nestjs-cqrs-module && rm -rf "$T"
skills/nestjs-cqrs-module/SKILL.mdNestJS CQRS Module Pattern
Architecture guide for building NestJS modules with CQRS, Event Sourcing, and clean separation of concerns. Extracted from production module
sub_payment-th3.
When to Use This Skill
- Creating a new NestJS sub-module
- Adding queries, commands, sagas to existing module
- Structuring models with business logic encapsulation
- Building external API integrations (proxy pattern)
- Mapping internal models to API responses
When NOT to Use This Skill
- Simple CRUD without business logic (use basic NestJS controller + service)
- Frontend/React code
- Database migration scripts
Module Directory Structure
sub_{module-name}/ ├── model/ # Domain models + business logic │ ├── {module}.model.ts # Core domain model (fromDatabase) │ ├── {module}-{context}.model.ts # Context-specific logic (validate, map) ├── queries/ # CQRS Read operations │ ├── get-{resource}-by-{criteria}.query.ts │ └── index.ts # export const QueryHandlers = [...] ├── commands/ # CQRS Write operations │ ├── {action}-{resource}.command.ts │ └── index.ts # export const CommandHandlers = [...] ├── sagas/ # Event-driven orchestration │ ├── {domain}.saga.ts │ └── index.ts # export const SagaHandlers = [...] ├── proxy/ # External HTTP calls │ ├── {domain}.proxy.ts │ └── index.ts # export const ProxyHandlers = [...] ├── repositories/ # Data access (raw SQL) │ └── {module}.repository.ts ├── dto/ # Input validation │ └── {action}-{module}.dto.ts ├── mapper/ # Response transformation │ └── {module}-{context}.mapper.ts ├── events/ # Event definitions ├── {module}.module.ts # DI wiring ├── {module}.service.ts # Orchestration (thin logic) ├── {module}.controller.ts # HTTP endpoints (thin) └── {module}.variables.ts # Enums + constants
Core Rules
1. Controller: Thin HTTP Layer
Controller only does: route + validate DTO + call service + map response.
@Post('request-payment') @HttpCode(200) async requestPayment(@Body() dto: RequestPaymentDto) { const result = await this.service.requestPayment(dto); return RequestMapper.toResponse(result); }
2. Service: Orchestration Only
Service coordinates steps, does NOT contain business logic. Delegate to models.
async requestPayment(dto: RequestPaymentDto) { // Validate via model const secret = RequestModel.validateSourceSystem(sourceSystem); // Parallel data fetching const [setting, program, existing] = await Promise.all([ this.settingService.getOne(SETTING_KEYS.PAYMENT_AMOUNT), this.queryBus.execute(new GetProgramQuery(dto.code)), this.queryBus.execute(new GetExistingQuery(dto.id)), ]); // Validate via model RequestModel.validateMinAmount(dto.amount, setting?.value); // Dispatch command return this.commandBus.execute(new CreateCommand(data)); }
3. Model: Business Logic Encapsulation
Models contain ALL validation, mapping, and domain logic as static methods.
export class PaymentRequestModel { // Validation static validateSourceSystem(source: any): string { ... } static validateMinAmount(amount: number, setting: any): void { ... } static validateDateRange(from: string, to: string): void { ... } // State checks static isAlreadyPaid(ps: PaymentSourceModel, tx: TransactionModel | null): boolean { ... } static isHashChanged(ps: PaymentSourceModel, hash: string): boolean { ... } // Data transformation static toCreateData(dto, programId, batchId) { ... } static toUpdateData(existingId, dto, programId, batchId) { ... } // Hash static async verifyHash(dto, secret): Promise<boolean> { ... } }
4. Query/Command: CQRS Pattern
Query (READ): class + handler in same file.
// get-{resource}-by-{criteria}.query.ts export class GetResourceByCriteriaQuery { constructor(public readonly criteria: string) {} } @QueryHandler(GetResourceByCriteriaQuery) export class GetResourceByCriteriaQueryHandler implements IQueryHandler<GetResourceByCriteriaQuery> { constructor(private readonly repo: ModuleRepository) {} async execute(query: GetResourceByCriteriaQuery) { const raw = await this.repo.getByCriteria(query.criteria); return raw ? ResourceModel.fromDatabase(raw) : null; } }
Command (WRITE): same pattern, use
@CommandHandler.
Index file exports array for module registration:
// queries/index.ts export const QueryHandlers = [ GetResourceByCriteriaQueryHandler, GetAnotherQueryHandler, ];
5. Repository: Raw SQL + Parameterized Queries
@Injectable() export class ModuleRepository { constructor( @PgSQLConnection(CONNECTION_STRING_DEFAULT) private readonly pg: PgSQLConnectionPool, ) {} async getByCriteria(criteria: string) { const sql = ` SELECT * FROM table_name WHERE column = $1 AND status != $2 LIMIT 1; `; const result = await this.pg.execute(sql, [criteria, Status.DELETED]); return result.rows[0] || null; } }
6. Mapper: Response Transformation
Always return ALL fields. Use
null instead of omitting fields.
export class StatusMapper { static toResponse(data: { status: string; source: Model; tx?: any }): StatusResponse { return { status: _.get(data, 'status', null), payment_code: _.get(data, 'source.payment_code', null), amount: _.get(data, 'tx.amount', null), paid_at: _.get(data, 'tx.time_update', null), }; } }
7. Variables: Module-Specific Enums
Define enums specific to the module context. Do NOT reuse enums from other modules for response types.
// Module-specific status (not tied to TransactionStatus) export enum ModuleCheckStatus { SUCCESS = 'SUCCESS', PENDING = 'PENDING', FAILED = 'FAILED', }
8. DTO: Validation with class-validator
export class RequestDto { @IsString() @IsNotEmpty() @ApiProperty({ example: 'DTTX' }) source_system: string; @IsNumber() @ApiProperty({ example: 5000000 }) total_amount: number; @IsDateString() @ApiProperty({ example: '2024-01-01' }) from_date: string; }
9. Proxy: External HTTP with Retry
@Injectable() export class WebhookProxy { constructor(private readonly httpService: HttpService) {} async call(url: string, payload: any, maxRetries = 3): Promise<any> { const { data } = await firstValueFrom( this.httpService.post(url, payload, { timeout: 10000 }).pipe( tap({ error: (e) => this.logger.warn(`Retry: ${e.message}`) }), retry({ count: maxRetries, delay: 1000 }), catchError((error) => throwError(() => error)), ), ); return data; } }
10. Saga: Event-Driven Workflow
@Injectable() export class WebhookSaga { @Saga() onPaymentSuccess = (events$: Observable<any>): Observable<ICommand> => { return events$.pipe( ofType(PaymentSuccessEvent), filter(event => !!event.paymentSource?.th3_info), map(event => new SendWebhookCommand(event.data)), ); }; }
11. Module Wiring
@Module({ imports: [ CqrsModule, HttpModule, PostgresModule.forFeature(CONNECTION_STRING_DEFAULT), // External module dependencies ], controllers: [ModuleController], providers: [ ModuleService, ModuleRepository, ...QueryHandlers, // Spread from index.ts ...CommandHandlers, ...SagaHandlers, ...ProxyHandlers, ], }) export class SubModuleModule {}
Data Flow
HTTP Request -> Controller (validate DTO, delegate) -> Service (orchestrate steps, parallel fetch) -> QueryBus/CommandBus -> Handler (execute, transform) -> Repository (raw SQL) -> Model.fromDatabase() (map to domain) -> Model.validate*() (business rules) -> Mapper.toResponse() (shape output) -> HTTP Response Event (async) -> Saga (filter, map to command) -> CommandHandler -> Proxy (external HTTP with retry)
Naming Conventions
| Type | Pattern | Example |
|---|---|---|
| Model | | |
| Query | | |
| Command | | |
| Handler | | |
| Saga | | |
| Proxy | | |
| Repository | | |
| Mapper | | |
| DTO | | |
| Enum | | |
| File | kebab-case | |
Anti-Patterns (Avoid)
- Business logic in service — Move to model static methods
- Optional response fields — Always return all fields, use
null - Reusing enums from other modules for response status — Create module-specific enums
- Sequential queries that can run parallel — Use
Promise.all - Raw data from query handler — Always map through
Model.fromDatabase() - Fat controller — Controller should only: validate DTO + call service + map response
- Direct repo calls in service — Use QueryBus/CommandBus for CQRS consistency