CQRS with @nestjs/cqrs: a practical guide for developers
Your CRM application has grown. The same endpoint that creates a lead is now trying to validate inventory, send notifications, update analytics, and trigger workflows all at once. By the time you add one more integration, the handler is 200 lines long and nobody wants to touch it. CQRS with @nestjs/cqrs: a practical guide for developers separates your code into commands (intent to change), queries (read operations), and events (side effects). This pattern doesn't eliminate complexity—it organizes it so each piece has one job.
What is CQRS and why does it matter
CQRS stands for Command Query Responsibility Segregation. The core idea is simple: split write operations (commands) from read operations (queries). Commands trigger state changes and publish events. Queries return data without side effects. Events let handlers react after a command succeeds, without the command handler knowing or caring who's listening.
In a NestJS service without CQRS, you might have this:
@Post('leads')
async assignLead(@Body() dto: AssignLeadDto, @Req() req) {
const lead = await this.leads.findById(dto.leadId);
lead.assignedTo = req.user.id;
lead.assignedAt = new Date();
await this.leads.save(lead);
// Now what? Notify the user?
await this.notifications.send(req.user.id, 'Lead assigned');
// Update the dashboard cache?
await this.cache.invalidate('dashboard:' + req.user.id);
// Log it somewhere?
await this.audit.log('LEAD_ASSIGNED', { leadId: dto.leadId, userId: req.user.id });
return { success: true };
}That handler owns the entire flow. When you add a new side effect—say, updating a stats table—you edit this same file. With CQRS, the handler is responsible only for the core mutation. Everything else subscribes to the event.
Commands — Intent to change state
A command represents the intent to perform an action. It's a data class that holds the information needed to execute that action. Each command is handled by exactly one handler, no more, no less.
First, define the command:
export class AssignLeadCommand {
constructor(
public readonly leadId: string,
public readonly userId: string,
) {}
}Then create the handler using the @CommandHandler decorator:
import { CommandHandler, ICommandHandler, EventBus } from '@nestjs/cqrs';
@CommandHandler(AssignLeadCommand)
export class AssignLeadHandler implements ICommandHandler {
constructor(
private readonly leadsRepository: LeadsRepository,
private readonly eventBus: EventBus,
) {}
async execute(command: AssignLeadCommand): Promise {
// Load
const lead = await this.leadsRepository.findById(command.leadId);
if (!lead) {
throw new NotFoundException('Lead not found');
}
// Modify
lead.assignedTo = command.userId;
lead.assignedAt = new Date();
// Save
await this.leadsRepository.save(lead);
// Publish event
this.eventBus.publish(
new LeadAssignedEvent(command.leadId, command.userId),
);
}
}Register the handler in your module:
@Module({
imports: [CqrsModule],
providers: [AssignLeadHandler, LeadsRepository],
})
export class LeadsModule {}In your controller, inject the CommandBus and dispatch the command:
@Post('leads/:id/assign')
async assignLead(
@Param('id') leadId: string,
@Body('userId') userId: string,
) {
await this.commandBus.execute(new AssignLeadCommand(leadId, userId));
return { success: true };
}The command handler is now a focused, testable unit. It knows how to load, mutate, and save. It doesn't know about notifications or caches.
Queries — Read-only handlers returning DTOs
Queries fetch and return data. They never modify state. A query handler is a clean read path with no side effects.
Define a query class:
export class GetLeadsByUserQuery {
constructor(public readonly userId: string) {}
}Create a DTO for the response:
export class LeadDto {
id: string;
name: string;
email: string;
assignedTo: string;
assignedAt: Date;
}Handle the query:
import { QueryHandler, IQueryHandler } from '@nestjs/cqrs';
@QueryHandler(GetLeadsByUserQuery)
export class GetLeadsByUserHandler
implements IQueryHandler {
constructor(private readonly leadsRepository: LeadsRepository) {}
async execute(query: GetLeadsByUserQuery): Promise {
const leads = await this.leadsRepository.findByAssignedTo(query.userId);
return leads.map((lead) => ({
id: lead.id,
name: lead.name,
email: lead.email,
assignedTo: lead.assignedTo,
assignedAt: lead.assignedAt,
}));
}
}Dispatch from a controller:
@Get('leads/user/:userId')
async getUserLeads(@Param('userId') userId: string) {
return this.queryBus.query(new GetLeadsByUserQuery(userId));
}Queries can read from any source—the primary database, a cache, a read replica, a search index. The bus doesn't care. Your controller doesn't care. You can swap the data source by changing one handler.
Events — Handlers react after commands succeed
Events are published after a command succeeds. They let other parts of the system respond without coupling them to the command handler. Multiple handlers can listen to the same event.
Define an event:
export class LeadAssignedEvent {
constructor(
public readonly leadId: string,
public readonly userId: string,
) {}
}Publish it from the command handler (as shown above with this.eventBus.publish(new LeadAssignedEvent(...))).
Now add event handlers. One might notify the user:
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
@EventsHandler(LeadAssignedEvent)
export class SendAssignmentNotificationHandler
implements IEventHandler {
constructor(private readonly notificationService: NotificationService) {}
async handle(event: LeadAssignedEvent): Promise {
await this.notificationService.sendToUser(
event.userId,
`You've been assigned a new lead: ${event.leadId}`,
);
}
}Another might update analytics:
@EventsHandler(LeadAssignedEvent)
export class UpdateLeadAssignmentStatsHandler
implements IEventHandler {
constructor(private readonly statsRepository: StatsRepository) {}
async handle(event: LeadAssignedEvent): Promise {
await this.statsRepository.incrementAssignmentCount(event.userId);
}
}Register all handlers as providers in your module. When the command bus executes the command, it publishes the event, and the event bus fires all matching handlers. If one handler fails, it doesn't prevent others from running (depending on your configuration). The command handler doesn't wait for them—fire and forget by default.
Use it where it helps — Context matters
CQRS shines in complex domains with rich business logic, multiple integrations, and teams working on different features. If your team maintains a CRM with workflows, audit trails, real-time dashboards, and third-party sync, CQRS separates concerns beautifully. Each handler is a single responsibility. Tests are simpler. Onboarding is clearer.
CQRS adds friction for simple CRUD. If your service is mostly "fetch a user, update a field, return the result," you've added files and boilerplate with no real gain. A straightforward controller with a service is fine there.
The practical rule: introduce CQRS when a single action triggers more than two or three side effects, or when side effects change frequently.
Common mistakes
- Handlers that do too much. A command handler that loads data, mutates it, saves it, and sends emails is just a different kind of God Method. Keep handlers focused. Use events for side effects.
- Forgetting to register handlers. If your handler isn't a provider in the module, the bus won't find it. Use the
CqrsModuleand ensure all command and event handlers are declared. - Treating events like commands. Events are facts about the past. They should be immutable and descriptive. They should not carry instructions like "now do this." Publish rich events; let handlers decide how to react.
- Publishing events that never get handled. If an event has no handler, it's overhead. Either remove it or add a handler. Conversely, don't expect event handlers to run synchronously in tests unless you configure them that way—async handlers may not complete before your test ends.
- Over-normalizing DTOs. Query handlers return DTOs. Map domain models to DTOs deliberately, not reflexively. A DTO is a contract for the API; shape it for the client, not the database.
Key takeaways
- Commands express intent to change state and are handled by exactly one handler that loads, mutates, saves, and publishes events.
- Queries are read-only operations that return DTOs; they can read from any source and don't trigger side effects.
- Events represent facts published after a command succeeds; multiple handlers can subscribe and react without coupling to the command.
- CQRS pays for itself in complex domains with many side effects or frequent changes; simple CRUD doesn't benefit.
- Keep handlers focused. Use events to decouple side effects. Register all handlers in your module and test them in isolation.






Comments
Loading comments…