Voltar para o blog
NestJSArquiteturaBackendCQRS

CQRS no NestJS: separando leituras de escritas antes que seja tarde demais

23 de junho de 202611 min de leiturapor Sávio Araújo

O problema antes da solução

Você tem um endpoint de listagem de pedidos que precisa retornar: pedidos do usuário, status de cada um, nome do produto, nome do cliente, e se há pendência financeira. Isso envolve 4 tabelas com JOINs e lógica de negócio para calcular o status.

O mesmo OrderService que processa pagamentos também responde essa query. Com o tempo, o service acumula métodos para operações completamente diferentes em natureza: processPayment() modifica estado e tem regras de negócio críticas; listOrdersForDashboard() só lê e precisa ser rápida.

CQRS (Command Query Responsibility Segregation) é o reconhecimento de que essas responsabilidades pertencem a lugares diferentes.

O que CQRS significa na prática

A ideia central é simples:

  • Commands: intenções de modificar estado. Retornam void ou um ID. Passam por validação e regras de negócio.
  • Queries: pedidos de leitura. Retornam dados. Podem usar modelos de leitura otimizados, sem passar pelo domínio.

Isso não obriga dois bancos de dados. Não obriga event sourcing. Não obriga microsserviços. Pode ser só uma separação de código dentro do mesmo monólito.

CQRS com o módulo oficial do NestJS

O NestJS tem o pacote @nestjs/cqrs que implementa commands, queries e events com um bus:

npm install @nestjs/cqrs
// app.module.ts
import { CqrsModule } from '@nestjs/cqrs';
 
@Module({
  imports: [CqrsModule],
})
export class AppModule {}

Commands

Um command é um objeto que representa a intenção de fazer algo:

// commands/create-order.command.ts
export class CreateOrderCommand {
  constructor(
    public readonly userId: string,
    public readonly items: Array<{ productId: string; quantity: number }>,
  ) {}
}

O handler processa o command:

// commands/create-order.handler.ts
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CreateOrderCommand } from './create-order.command';
 
@CommandHandler(CreateOrderCommand)
export class CreateOrderHandler implements ICommandHandler<CreateOrderCommand> {
  constructor(
    private readonly orderRepository: OrderRepository,
    private readonly productService: ProductService,
  ) {}
 
  async execute(command: CreateOrderCommand): Promise<string> {
    const { userId, items } = command;
 
    // Validação de negócio
    await this.productService.validateStock(items);
 
    // Criação do agregado
    const order = Order.create(userId, items);
 
    await this.orderRepository.save(order);
 
    return order.id;
  }
}

O controller despacha o command pelo bus:

// orders.controller.ts
import { CommandBus } from '@nestjs/cqrs';
import { CreateOrderCommand } from './commands/create-order.command';
 
@Controller('orders')
export class OrdersController {
  constructor(private readonly commandBus: CommandBus) {}
 
  @Post()
  async createOrder(@Body() dto: CreateOrderDto, @CurrentUser() user: User) {
    const orderId = await this.commandBus.execute(
      new CreateOrderCommand(user.id, dto.items),
    );
    return { id: orderId };
  }
}

Queries

Queries são independentes do domínio. Podem ir direto no banco com SQL otimizado, sem passar pelo repositório e pelo agregado:

// queries/list-orders.query.ts
export class ListOrdersQuery {
  constructor(
    public readonly userId: string,
    public readonly page: number,
    public readonly limit: number,
  ) {}
}
// queries/list-orders.handler.ts
import { QueryHandler, IQueryHandler } from '@nestjs/cqrs';
import { ListOrdersQuery } from './list-orders.query';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
 
@QueryHandler(ListOrdersQuery)
export class ListOrdersHandler implements IQueryHandler<ListOrdersQuery> {
  constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
 
  async execute(query: ListOrdersQuery) {
    const { userId, page, limit } = query;
    const offset = (page - 1) * limit;
 
    // SQL otimizado diretamente para a tela
    const rows = await this.dataSource.query(
      `
      SELECT
        o.id,
        o.status,
        o.created_at,
        COUNT(oi.id) AS item_count,
        SUM(oi.quantity * p.price) AS total
      FROM orders o
      JOIN order_items oi ON oi.order_id = o.id
      JOIN products p ON p.id = oi.product_id
      WHERE o.user_id = $1
        AND o.deleted_at IS NULL
      GROUP BY o.id
      ORDER BY o.created_at DESC
      LIMIT $2 OFFSET $3
      `,
      [userId, limit, offset],
    );
 
    return rows;
  }
}

Repare: o handler de query não usa o OrderRepository, não instancia a entidade Order, não chama o OrderService. Ele vai direto no banco com uma query projetada para o que a tela precisa.

Registrando handlers no módulo

// orders.module.ts
import { CqrsModule } from '@nestjs/cqrs';
import { CreateOrderHandler } from './commands/create-order.handler';
import { ListOrdersHandler } from './queries/list-orders.handler';
 
const CommandHandlers = [CreateOrderHandler];
const QueryHandlers = [ListOrdersHandler];
 
@Module({
  imports: [CqrsModule],
  controllers: [OrdersController],
  providers: [...CommandHandlers, ...QueryHandlers],
})
export class OrdersModule {}

Events

Commands frequentemente disparam eventos que outros módulos precisam reagir:

// events/order-created.event.ts
export class OrderCreatedEvent {
  constructor(
    public readonly orderId: string,
    public readonly userId: string,
  ) {}
}
// commands/create-order.handler.ts
import { EventBus } from '@nestjs/cqrs';
import { OrderCreatedEvent } from '../events/order-created.event';
 
@CommandHandler(CreateOrderCommand)
export class CreateOrderHandler {
  constructor(
    private readonly orderRepository: OrderRepository,
    private readonly eventBus: EventBus,
  ) {}
 
  async execute(command: CreateOrderCommand) {
    const order = Order.create(command.userId, command.items);
    await this.orderRepository.save(order);
 
    this.eventBus.publish(new OrderCreatedEvent(order.id, command.userId));
 
    return order.id;
  }
}

O módulo de notificações reage sem acoplamento:

// notifications/handlers/order-created.handler.ts
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
 
@EventsHandler(OrderCreatedEvent)
export class OrderCreatedNotificationHandler implements IEventHandler<OrderCreatedEvent> {
  async handle(event: OrderCreatedEvent) {
    await this.notificationService.sendOrderConfirmation(event.userId, event.orderId);
  }
}

Estrutura de pastas

Uma organização que funciona na prática:

orders/
  commands/
    create-order.command.ts
    create-order.handler.ts
    cancel-order.command.ts
    cancel-order.handler.ts
  queries/
    list-orders.query.ts
    list-orders.handler.ts
    get-order-detail.query.ts
    get-order-detail.handler.ts
  events/
    order-created.event.ts
    order-cancelled.event.ts
  orders.module.ts
  orders.controller.ts

Cada command/query é um arquivo pequeno e focado. Nenhum "god service" com 30 métodos.

Quando CQRS vale o custo

CQRS adiciona arquivos e indireção. Não é gratuito. Vale quando:

  • Reads e writes têm requisitos muito diferentes: queries precisam de JOINs complexos ou agregações que não fazem sentido no modelo de escrita
  • O volume de leitura é muito maior que escrita: você pode escalar o lado de leitura independentemente (réplica de banco, cache, modelo desnormalizado)
  • O modelo de domínio ficou difícil de testar: handlers de command pequenos são muito mais fáceis de testar unitariamente do que services gordos
  • Você quer habilitar event sourcing no futuro: CQRS é o pré-requisito natural

Não vale quando:

  • CRUD simples sem regras de negócio
  • Time pequeno sem familiaridade com o padrão
  • Você ainda está descobrindo o domínio (CQRS antes de entender o domínio solidifica decisões erradas)

Quer conversar sobre esse tema?

Se você tem dúvidas, quer aplicar isso num projeto ou só quer trocar uma ideia, pode me chamar.

Falar com a SA Tech