Skip to content

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.

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:

api/src/users/mappers/create-user.mapper.ts
/* 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 }),
};

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:

api/src/users/application/cqrs/commands/create-user.command.ts
/* 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,
) {}

@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:

api/src/users/application/cqrs/commands/create-user.handler.ts
/* 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;
}
}

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:

api/src/users/application/cqrs/events/user-created.handler.ts
/* 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,
});
}
}