| name | nestjs-cqrs-module |
| description | 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". |
NestJS 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) {
const secret = RequestModel.validateSourceSystem(sourceSystem);
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)),
]);
RequestModel.validateMinAmount(dto.amount, setting?.value);
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 {
static validateSourceSystem(source: any): string { ... }
static validateMinAmount(amount: number, setting: any): void { ... }
static validateDateRange(from: string, to: string): void { ... }
static isAlreadyPaid(ps: PaymentSourceModel, tx: TransactionModel | null): boolean { ... }
static isHashChanged(ps: PaymentSourceModel, hash: string): boolean { ... }
static toCreateData(dto, programId, batchId) { ... }
static toUpdateData(existingId, dto, programId, batchId) { ... }
static async verifyHash(dto, secret): Promise<boolean> { ... }
}
4. Query/Command: CQRS Pattern
Query (READ): class + handler in same file.
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:
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.
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),
],
controllers: [ModuleController],
providers: [
ModuleService,
ModuleRepository,
...QueryHandlers,
...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 | {Domain}Model | PaymentSourceTH3Model |
| Query | Get{Resource}By{Criteria}Query | GetPaymentSourceBySourceAndCodeQuery |
| Command | {Action}{Resource}Command | CreatePaymentSourceTH3Command |
| Handler | {Query/Command}Handler | CreatePaymentSourceTH3CommandHandler |
| Saga | {Domain}Saga | TH3WebhookSaga |
| Proxy | {Domain}Proxy | TH3WebhookProxy |
| Repository | {Module}Repository | SubPaymentTh3Repository |
| Mapper | {Module}{Context}Mapper | SubPaymentTh3StatusMapper |
| DTO | {Action}{Module}Dto | RequestPaymentTH3Dto |
| Enum | {Prefix}{Type} | TH3CheckStatus |
| File | kebab-case | get-payment-source-by-code.query.ts |
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