CQRS
CQRS — Command Query Responsibility Segregation — means reads and writes travel separate paths. Commands change state and return nothing meaningful; queries read state and return DTOs. ForgeStack implements this on top of NestJS's CQRS module, with base classes that give every handler the same cross-cutting behaviour.
Commands
A command is a plain, immutable request to change something. It extends
Base_Command, which carries the optional requesterUserId used for
authorization:
// …/application/commands/register-user/register-user.command.ts
export class RegisterUser_Command extends Base_Command implements ICommand {
public readonly email: string;
public readonly password: string;
constructor(props: { email: string; password: string }) {
super();
this.email = props.email;
this.password = props.password;
}
}
The command handler base
Handlers don't implement execute directly. They extend the
Base_CommandHandler(Command) mixin and implement three methods —
authorize, validate, handle. The base wraps them with a tracing span,
structured logging, metrics, and a uniform try/finally:
// libs/common/src/cqrs/base.command-handler.ts
export function Base_CommandHandler<TCommand extends ICommand>(
command: new (...args: any[]) => TCommand,
) {
@CommandHandler(command)
abstract class Base_CommandHandlerClass {
@WithSpan('command.execute', { attributesFrom: ['constructor.name'] })
async execute(command: TCommand) {
const startTime = process.hrtime.bigint();
let status: 'success' | 'error' = 'success';
try {
await this.authorize(command);
await this.validate(command);
return await this.handle(command);
} catch (error) {
status = 'error';
throw error;
} finally {
const duration = Number(process.hrtime.bigint() - startTime) / 1e9;
getCqrsMetrics()?.observeCommand(command.constructor.name, duration, status);
}
}
abstract handle(command: TCommand): Promise<void>;
abstract authorize(command: TCommand): Promise<boolean>;
abstract validate(command: TCommand): Promise<void>;
// helpers available to every handler:
async sendDomainEvents<T extends SharedAggregate>(entity: T): Promise<void> { /* … */ }
async sendIntegrationEvent<T extends Base_IntegrationEvent>(event: T, ctx): Promise<void> { /* … */ }
}
return Base_CommandHandlerClass;
}
A concrete handler
handle is where the use case is orchestrated: create the aggregate (the rule
runs there), persist it in a transaction, then publish its domain events
after the transaction commits.
// …/commands/register-user/register-user.command-handler.ts
export class RegisterUser_CommandHandler extends Base_CommandHandler(RegisterUser_Command) {
constructor(
@Inject(USER_REPOSITORY) private readonly userRepository: User_Repository,
@Inject(USER_UNIQUENESS_CHECKER) private readonly uniquenessChecker: IUserUniquenessChecker,
@Inject(EVENT_BUS) eventBus: IEventBus,
@Inject(OUTBOX_REPOSITORY) outboxRepository: Outbox_Repository,
) {
super(eventBus, outboxRepository);
}
async handle(command: RegisterUser_Command) {
const user = await User.create(
{
email: new Email(command.email),
password: await Password.createFromPlainText(command.password),
},
this.uniquenessChecker,
);
await Transaction.run(async (context) => {
await this.userRepository.save(user, context);
});
await this.sendDomainEvents<User>(user);
}
async authorize(_: RegisterUser_Command) { return true; }
async validate(_: RegisterUser_Command) {}
}
authorize → validate → handle
Splitting the three concerns keeps handlers focused. authorize answers "is
the requester allowed?", validate checks the command's shape/preconditions,
and handle performs the work. The base class guarantees they always run in
that order.
Queries
A query reads. It extends Base_Query and is handled by a
Base_QueryHandler(Query)<Response>() — note the extra type parameter for the
return type, since queries (unlike commands) return data:
// libs/common/src/cqrs/base.query-handler.ts
export function Base_QueryHandler<TQuery extends IQuery>(query) {
return function <TResult extends object>() {
@QueryHandler(query)
abstract class Base_QueryHandlerClass {
@WithSpan('query.execute', { attributesFrom: ['constructor.name'] })
async execute(query: TQuery): Promise<TResult> {
// authorize → validate → handle, with metrics + tracing (as commands)
await this.authorize(query);
await this.validate(query);
return await this.handle(query);
}
abstract handle(query: TQuery): Promise<TResult>;
abstract authorize(query: TQuery): Promise<boolean>;
abstract validate(query: TQuery): Promise<void>;
}
return Base_QueryHandlerClass;
};
}
The login query reads a user, verifies the password, runs a domain check, and returns tokens — no aggregate is mutated, nothing is published:
// …/queries/get-new-tokens-from-user-credentials/…query-handler.ts
export class GetNewTokensFromUserCredentials_QueryHandler extends Base_QueryHandler(
GetNewTokensFromUserCredentials_Query,
)<GetNewTokensFromUserCredentials_QueryResponse>() {
async handle(query) {
const user = await this.userRepository.findByEmail(new Email(query.email));
if (!user) throw new UnauthorizedException();
if (!(await user.password.verify(query.password))) throw new UnauthorizedException();
user.canAuthenticate(); // domain rule (status checks)
const payload: TokenPayload = {
userId: user.id.toValue(),
email: user.email.toValue(),
role: user.role.toValue(),
};
return {
userId: user.id.toValue(),
accessToken: this.jwtTokenService.generateAccessToken(payload),
refreshToken: this.jwtTokenService.generateRefreshToken(payload),
};
}
}
What you get for free
Because every command and query goes through these bases, you automatically get, per execution:
- a tracing span (
command.execute/query.execute) named after the class; - metrics — duration histogram + success/error counter (visible on the CQRS Grafana dashboard);
- structured logs with correlation IDs;
- a consistent authorize → validate → handle lifecycle.
The command-handler-base / query-handler-base lint rules ensure handlers
actually extend these bases, and handler-naming enforces the _CommandHandler
/ _QueryHandler suffixes.
Next: Event-Driven Architecture.