Skip to content
ExponaBLOG
NESTJS · DAY 046 / 100

CQRS with @nestjs/cqrs: a practical guide for developers

Commands, queries and events with a built-in bus.

Expona Admin7 min read

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:

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

typescript
export class AssignLeadCommand {
  constructor(
    public readonly leadId: string,
    public readonly userId: string,
  ) {}
}

Then create the handler using the @CommandHandler decorator:

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

typescript
@Module({
  imports: [CqrsModule],
  providers: [AssignLeadHandler, LeadsRepository],
})
export class LeadsModule {}

In your controller, inject the CommandBus and dispatch the command:

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

typescript
export class GetLeadsByUserQuery {
  constructor(public readonly userId: string) {}
}

Create a DTO for the response:

typescript
export class LeadDto {
  id: string;
  name: string;
  email: string;
  assignedTo: string;
  assignedAt: Date;
}

Handle the query:

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

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

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

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

typescript
@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 CqrsModule and 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.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles