CQRS
Reads and writes want different things. A write must enforce business rules, run in a transaction and announce what changed. A read wants to fetch data and shape it for whoever is asking, as directly as possible. When both go through the same service methods, each one drags the other's baggage: reads pass through write-oriented models, writes accumulate presentation concerns, and the methods grow into do-everything functions.
CQRS (Command Query Responsibility Segregation) splits the two paths.
A command changes state and returns nothing. A query
answers a question and changes nothing. Every use case in the system is one or
the other, and the split is visible in the folder structure of every bounded
context: application/commands/ and application/queries/.
Commands
A command is an immutable object describing one intention, named in the
imperative: RegisterUser_Command, CreateGreeting_Command,
RevokeAllUserTokens_Command. It carries data, not behaviour:
// …/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;
}
}Every command must extend Base_Command.
Each command has exactly one handler, in the same folder. A handler implements three methods, and the framework always runs them in the same order:
authorize— is the requester allowed to do this?validate— are the command's preconditions met?handle— do the work.
handle orchestrates but does not decide: a typical command handler builds value objects, calls the
aggregate (where the business rules run), persists inside a transaction, and
dispatches the aggregate's domain events:
// …/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,
) {
super(eventBus);
}
async handle(command: RegisterUser_Command) {
// wrap the raw input in value objects: invalid data can't get past this point
const email = new Email(command.email);
const password = await Password.createFromPlainText(command.password);
// the aggregate creates the user: the business rules run inside it
const user = await User.create({ email, password }, this.uniquenessChecker);
// persist through the repository port
await this.userRepository.save(user);
// dispatch the domain events the aggregate recorded
await this.sendDomainEvents<User>(user);
}
async authorize(_: RegisterUser_Command) { return true; }
async validate(_: RegisterUser_Command) {}
}Note what the handler returns: nothing. If a caller wants to see the result of a command, it asks a query afterwards, or reacts to the events the command caused. That one-way flow is what keeps writes clean.
Queries
A query reads state and returns a response object shaped for the caller,
never a domain object. Every query must extend Base_Query. It has a handler
with the same authorize → validate → handle lifecycle, with one difference:
handle returns data.
A query carries its parameters, and its response type declares exactly what the caller gets back:
// …/application/queries/list-greetings/list-greetings.query.ts
export class ListGreetings_Query extends Base_Query implements IQuery {
constructor(public readonly limit: number = 10) {
super();
}
}
// list-greetings.query-response.ts
export interface ListGreetings_QueryResponse {
greetings: Array<{
id: string;
message: string;
source: string;
createdAt: Date;
}>;
}The handler reads through a repository port and maps the domain objects to that plain response. No aggregate is mutated and no event is published:
// list-greetings.query-handler.ts
export class ListGreetings_QueryHandler extends Base_QueryHandler(
ListGreetings_Query,
)<ListGreetings_QueryResponse>() {
constructor(
@Inject(GREETING_REPOSITORY) private readonly greetingRepository: Greeting_Repository,
) {
super();
}
async handle(query: ListGreetings_Query): Promise<ListGreetings_QueryResponse> {
// read through the repository port
const greetings = await this.greetingRepository.findLatest(query.limit);
// map domain objects to the plain response shaped for the caller
return {
greetings: greetings.map((g) => ({
id: g.id.toValue(),
message: g.message.toValue(),
source: g.source,
createdAt: g.timestamps.createdAt.toValue(),
})),
};
}
async authorize(_: ListGreetings_Query) { return true; }
async validate(_: ListGreetings_Query) {}
}Controllers
Commands and queries enter the system through controllers, the HTTP side
of a context's interfaces/ layer. A controller is deliberately thin: it
validates the raw input, builds the command or query, and dispatches it
through the injected bus. No business logic lives here:
// …/interfaces/controllers/greetings/create-greeting.controller.ts
@ApiTags('greetings')
@Controller('greetings')
export class CreateGreeting_Controller {
constructor(@Inject(COMMAND_BUS) private readonly commandBus: ICommandBus) {}
@Post()
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: 'Create a greeting' })
async createGreeting(@Body() body: CreateGreeting_ControllerParams) {
await this.commandBus.execute(new CreateGreeting_Command(body.message, 'api'));
}
}The conventions:
- One controller per operation, named after it
(
CreateGreeting_Controller), ininterfaces/controllers/<resource>/. - Input validation at the edge — a
…_ControllerParamsclass withclass-validatordecorators, enforced by the global validation pipe. Domain rules still live in value objects; the params class only guards shape. - Controllers use the command bus and the query bus (injected via the
COMMAND_BUSandQUERY_BUStokens) to dispatch commands and queries; the bus finds the handler, so the controller never sees one directly. - Swagger decorators feed the docs UI every service serves at
/docs.
What you get with this base classes
Every handler extends a shared base class (Base_CommandHandler or
Base_QueryHandler). The base is what runs the
authorize → validate → handle sequence, and it wraps every execution with:
- a tracing span (
command.execute/query.execute) named after the handler class; - metrics — a duration histogram and success/error counters, visible on the CQRS Grafana dashboard;
- structured logs with correlation IDs.
Further reading
- CQRS — Martin Fowler. A short, balanced overview, including when not to use it.
- CQRS Documents — Greg Young. The original, deeper treatment by the person who coined the term.
Next: Repositories.