A command through every layer
This recipe follows POST /users of users-api, the repository’s example application. Every
file below is the application’s real code, shown as it is built and tested.
1. The route maps the body to a command
Section titled “1. The route maps the body to a command”The controller declares the body schema with @Body({ schema: CreateUserDtoSchema }), which
Nest’s StandardSchemaValidationPipe validates (see Validation with
Zod), reads the optional Idempotency-Key header, and hands
both to a mapper. createZodMapper turns the request DTO into the command:
/* Copyright (C) 2026-present Aristotelis — see repository license. */
import { createZodMapper } from '@nestjs-pipeline/zod';import { z } from 'zod';import { CreateUserCommand } from '../application/cqrs/commands/create-user.command.js';import { type CreateUserDto, CreateUserDtoSchema,} from '../dtos/create-user.dto.js';
const base = createZodMapper( CreateUserDtoSchema.extend({ idempotencyKey: z.string().optional(), }).transform( ({ name, email, department, idempotencyKey }) => new CreateUserCommand({ username: name, email, ...(department !== undefined ? { department } : {}), ...(idempotencyKey !== undefined ? { idempotencyKey } : {}), }), ),);
export const CreateUserMapper = { ...base, map: (dto: CreateUserDto, idempotencyKey?: string) => base.map({ ...dto, idempotencyKey }),};2. The command carries its own schema
Section titled “2. The command carries its own schema”createCommand gives the command class its Zod schema, here built from the domain’s own
length rules (User.rules). ZodValidationBehavior, registered as a global behavior, parses
every command against that schema before its handler runs, so a command that reaches the
handler is valid whoever dispatched it:
/* Copyright (C) 2026-present Aristotelis — see repository license. */
import { EmailSchema } from '@common/validation/email.schema.js';import { IdempotencyKeySchema } from '@common/validation/idempotency-key.schema.js';import { BaseCommand } from '@cqrs-ddd/core/application';import { createCommand } from '@nestjs-pipeline/zod';import { z } from 'zod';import { User } from '../../../domain/models/user.entity.js';
export class CreateUserCommand extends createCommand( z.object({ username: z .string() .trim() .min(User.rules.username.minLength) .max(User.rules.username.maxLength), email: EmailSchema, department: z .string() .trim() .min(User.rules.department.minLength) .max(User.rules.department.maxLength) .optional(), idempotencyKey: IdempotencyKeySchema.optional(), }), BaseCommand,) {}3. The handler declares its pipeline
Section titled “3. The handler declares its pipeline”@UsePipeline stacks the behaviors this command needs. They run after the global before
behaviors and in the order listed (see Pipeline execution
model):
| Behavior | What it does here | Package |
|---|---|---|
logging |
Logs the command; a duplicate email (UniqueEmailException) logs as a warning, not an error |
core |
requires |
The type-level check: the caller may create a User at all |
casl |
featureFlag |
Refuses the command while the user-registration flag is off |
feature-flags |
rateLimit |
Spends RATE_LIMIT_COST.createUser points from the caller’s bucket |
rate-limit |
idempotent |
Replays the result of a retried operation with the same Idempotency-Key, for the same caller and permissions |
idempotency |
audit |
Records who created which user, and the outcome | audit |
Inside handle, CaslAuthorizer.authorize checks the new entity and the fields being
written. That entity-level check belongs in the handler, after the aggregate exists; the
type-level requires above cannot see the entity. CommandBaseHandler then publishes the
aggregate’s buffered events once handle returns:
/* Copyright (C) 2026-present Aristotelis — see repository license. */
import { APP_ACTIONS, APP_SUBJECTS, AUDIT_ACTIONS, RATE_LIMIT_COST,} from '@common/constants/index.js';import { sessionPrincipalKey } from '@common/context/session-principal.store.js';import { operationIdempotencyKeyFactory } from '@common/idempotency/operation-key.js';import { CommandBaseHandler, ICommandRepository,} from '@cqrs-ddd/core/application';import { Inject } from '@nestjs/common';import { CommandHandler, EventBus } from '@nestjs/cqrs';import { AUDIT_SEVERITY, audit } from '@nestjs-pipeline/audit';import { CaslAuthorizer, requireAbilityDigest, requires,} from '@nestjs-pipeline/casl';import { logging, UsePipeline } from '@nestjs-pipeline/core';import { featureFlag } from '@nestjs-pipeline/feature-flags';import { idempotent } from '@nestjs-pipeline/idempotency';import { createPartitionedRateLimitKeyFactory, rateLimit,} from '@nestjs-pipeline/rate-limit';import { UniqueEmailException } from '../../../domain/models/errors/email.exception.js';import { User, type UserSnapshot } from '../../../domain/models/user.entity.js';import { COMMAND_REPOSITORY } from '../../../persistence/repository.tokens.js';import { CreateUserCommand } from './create-user.command.js';
@CommandHandler(CreateUserCommand)@UsePipeline( logging({ mapLogLevel: new Map([[UniqueEmailException, 'warn']]), }), requires({ action: APP_ACTIONS.CREATE, subject: APP_SUBJECTS.USER }), featureFlag({ flag: 'user-registration' }), rateLimit({ keyFactory: createPartitionedRateLimitKeyFactory(sessionPrincipalKey), points: RATE_LIMIT_COST.createUser, }), idempotent({ keyFactory: operationIdempotencyKeyFactory( 'user.create', (ctx) => (ctx.request as CreateUserCommand).idempotencyKey, ), replayScopeFactory: requireAbilityDigest, }), audit({ action: AUDIT_ACTIONS.USER_CREATE, severity: AUDIT_SEVERITY.MEDIUM, }),)export class CreateUserHandler extends CommandBaseHandler< CreateUserCommand, User> { constructor( @Inject(COMMAND_REPOSITORY.createUser) private readonly commandRepository: ICommandRepository<User, UserSnapshot>, private readonly authorizer: CaslAuthorizer, protected readonly eventBus: EventBus, ) { super(eventBus); }
async handle(command: CreateUserCommand): Promise<User> { const { username, email, department } = command; const user = User.create(username, email, department); this.authorizer.authorize('create', user, [ 'username', 'email', ...(department !== undefined ? ['department'] : []), ]); await this.commandRepository.save(user); return user; }}4. The event starts the follow-up work
Section titled “4. The event starts the follow-up work”User.create recorded a UserCreatedEvent. Its handler enqueues the welcome email, and
deadLetter({ rethrow: false }) captures a failure as a dead letter instead of failing the
command that already succeeded. Jobs that keep the request’s
context follows it into the queue:
/* Copyright (C) 2026-present Aristotelis — see repository license. */
import { Inject } from '@nestjs/common';import { EventsHandler, type IEventHandler } from '@nestjs/cqrs';import { UsePipeline } from '@nestjs-pipeline/core';import { deadLetter } from '@nestjs-pipeline/deadletter';import { UserCreatedEvent } from '../../../domain/events/user-created.event.js';import { type IWelcomeEmailDispatcher, WELCOME_EMAIL_DISPATCHER,} from '../../ports/user-event-dispatcher.port.js';
@EventsHandler(UserCreatedEvent)@UsePipeline(deadLetter({ rethrow: false }))export class UserCreatedHandler implements IEventHandler<UserCreatedEvent> { constructor( @Inject(WELCOME_EMAIL_DISPATCHER) private readonly welcomeEmailDispatcher: IWelcomeEmailDispatcher, ) {}
async handle(event: UserCreatedEvent): Promise<void> { const { id: userId, username, email } = event.payload;
await this.welcomeEmailDispatcher.enqueueWelcomeEmail({ userId, username, email, }); }}