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\".

install
source · Clone the upstream repo
git clone https://github.com/QuocTang/battle-skills
Claude Code · Install into ~/.claude/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"
manifest: skills/nestjs-cqrs-module/SKILL.md
source content

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) {
  // 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

TypePatternExample
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
Filekebab-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