CQRS com TypeScript: Commands, Queries e Handlers Tipados: Do Básico ao Avançado Já leu

O que é CQRS e Por Que Usar CQRS (Command Query Responsibility Segregation) é um padrão arquitetural que separa as operações de leitura (queries) das operações de escrita (commands) em seus próprios objetos e fluxos de processamento. A ideia central é simples: um comando muda o estado do sistema, enquanto uma query apenas lê dados sem efeitos colaterais. Isso permite que você escale, otimize e mantenha esses dois fluxos de forma independente. Em aplicações TypeScript tradicionais, você provavelmente tem um controlador que recebe uma requisição, valida dados, chama um serviço que tanto lê quanto escreve, e retorna uma resposta. O problema cresce quando sua aplicação fica complexa: operações de leitura podem precisar de otimizações diferentes (cache, denormalização), enquanto operações de escrita precisam de transações e validações rígidas. CQRS resolve isso criando dois caminhos distintos, cada um otimizado para seu propósito. Estrutura Fundamental: Commands e Queries O que é um Command Um command é uma intenção de mudar algo no sistema.

O que é CQRS e Por Que Usar

CQRS (Command Query Responsibility Segregation) é um padrão arquitetural que separa as operações de leitura (queries) das operações de escrita (commands) em seus próprios objetos e fluxos de processamento. A ideia central é simples: um comando muda o estado do sistema, enquanto uma query apenas lê dados sem efeitos colaterais. Isso permite que você escale, otimize e mantenha esses dois fluxos de forma independente.

Em aplicações TypeScript tradicionais, você provavelmente tem um controlador que recebe uma requisição, valida dados, chama um serviço que tanto lê quanto escreve, e retorna uma resposta. O problema cresce quando sua aplicação fica complexa: operações de leitura podem precisar de otimizações diferentes (cache, denormalização), enquanto operações de escrita precisam de transações e validações rígidas. CQRS resolve isso criando dois caminhos distintos, cada um otimizado para seu propósito.

Estrutura Fundamental: Commands e Queries

O que é um Command

Um command é uma intenção de mudar algo no sistema. Diferente de um método tradicional, um command é um objeto que encapsula essa intenção e seus dados necessários. Ele é imutável, fortemente tipado e descreve o que deve acontecer, não como.

// Define a estrutura de um command genérico
interface Command {
  type: string;
}

// Um command concreto para criar um usuário
class CreateUserCommand implements Command {
  readonly type = 'CreateUser';

  constructor(
    public readonly id: string,
    public readonly name: string,
    public readonly email: string
  ) {}
}

// Outro command para atualizar um usuário
class UpdateUserEmailCommand implements Command {
  readonly type = 'UpdateUserEmail';

  constructor(
    public readonly userId: string,
    public readonly newEmail: string
  ) {}
}

Notice que cada command é uma classe específica. Isso garante type-safety total em TypeScript. Quando você cria um CreateUserCommand, você não esquecerá nenhum campo obrigatório—o compilador não vai deixar compilar.

O que é uma Query

Uma query é um pedido para ler dados sem efeitos colaterais. Assim como commands, queries são objetos tipados que descrevem exatamente o que você quer saber.

// Estrutura genérica de uma query
interface Query {
  type: string;
}

// Uma query para buscar um usuário por ID
class GetUserByIdQuery implements Query {
  readonly type = 'GetUserById';

  constructor(public readonly userId: string) {}
}

// Uma query para listar todos os usuários com paginação
class ListUsersQuery implements Query {
  readonly type = 'ListUsers';

  constructor(
    public readonly page: number,
    public readonly limit: number
  ) {}
}

// Uma query para buscar estatísticas
class GetUserStatisticsQuery implements Query {
  readonly type = 'GetUserStatistics';

  constructor(public readonly period: 'day' | 'week' | 'month') {}
}

A diferença semântica é clara: queries retornam dados, commands não. Se você quiser retornar algo de um command (como o ID gerado de um novo usuário), você deve pensar se aquilo realmente é um efeito colateral do comando ou uma query separada.

Handlers Tipados: A Máquina de Executar

Command Handlers

Um command handler é responsável por executar a lógica do comando. Ele recebe o command, valida se necessário, modifica o estado (banco de dados, memória, etc.) e pode retornar um resultado. Em CQRS puro, commands não retornam valores, mas em aplicações reais é comum retornar o ID do recurso criado ou um status de sucesso.

// Tipo genérico para um handler de command
interface CommandHandler<T extends Command, R = void> {
  execute(command: T): Promise<R>;
}

// Interface de um repositório para persistência
interface UserRepository {
  save(user: User): Promise<void>;
  findById(id: string): Promise<User | null>;
}

// Um handler para CreateUserCommand
class CreateUserCommandHandler implements CommandHandler<CreateUserCommand, string> {
  constructor(private userRepository: UserRepository) {}

  async execute(command: CreateUserCommand): Promise<string> {
    // Validação
    if (!command.email.includes('@')) {
      throw new Error('Email inválido');
    }

    // Criar o usuário
    const user = new User(command.id, command.name, command.email);

    // Persistir
    await this.userRepository.save(user);

    // Retornar o ID criado
    return user.id;
  }
}

// Um handler para UpdateUserEmailCommand
class UpdateUserEmailCommandHandler implements CommandHandler<UpdateUserEmailCommand> {
  constructor(private userRepository: UserRepository) {}

  async execute(command: UpdateUserEmailCommand): Promise<void> {
    const user = await this.userRepository.findById(command.userId);

    if (!user) {
      throw new Error('Usuário não encontrado');
    }

    user.email = command.newEmail;
    await this.userRepository.save(user);
  }
}

Query Handlers

Um query handler recebe uma query e retorna dados. Diferente de commands, queries sempre retornam algo. Query handlers geralmente não têm efeitos colaterais e podem ser otimizados com caching.

// Tipo genérico para um handler de query
interface QueryHandler<T extends Query, R> {
  execute(query: T): Promise<R>;
}

// Modelo de retorno para uma query de usuário
interface UserDTO {
  id: string;
  name: string;
  email: string;
}

// Um handler para GetUserByIdQuery
class GetUserByIdQueryHandler implements QueryHandler<GetUserByIdQuery, UserDTO | null> {
  constructor(private userRepository: UserRepository) {}

  async execute(query: GetUserByIdQuery): Promise<UserDTO | null> {
    const user = await this.userRepository.findById(query.userId);

    if (!user) {
      return null;
    }

    // Retornar DTO (Data Transfer Object)
    return {
      id: user.id,
      name: user.name,
      email: user.email,
    };
  }
}

// Um handler para ListUsersQuery
class ListUsersQueryHandler implements QueryHandler<ListUsersQuery, { users: UserDTO[]; total: number }> {
  constructor(private userRepository: UserRepository) {}

  async execute(query: ListUsersQuery): Promise<{ users: UserDTO[]; total: number }> {
    // Aqui você poderia usar uma view denormalizada para leitura otimizada
    const users = await this.userRepository.list(query.page, query.limit);

    return {
      users: users.map(u => ({
        id: u.id,
        name: u.name,
        email: u.email,
      })),
      total: await this.userRepository.count(),
    };
  }
}

Bus de Commands e Queries: Orquestração Centralizada

Implementando um Bus

O bus é o coração do padrão CQRS. Ele registra os handlers e, quando você envia um command ou query, ele encontra o handler correto e o executa. Isso desacopla seu código: você não precisa saber qual handler vai executar, apenas envia a mensagem.

// Definição do bus de commands
class CommandBus {
  private handlers: Map<string, CommandHandler<any, any>> = new Map();

  register<T extends Command, R = void>(
    commandType: string,
    handler: CommandHandler<T, R>
  ): void {
    this.handlers.set(commandType, handler);
  }

  async execute<T extends Command, R = void>(command: T): Promise<R> {
    const handler = this.handlers.get(command.type);

    if (!handler) {
      throw new Error(`Nenhum handler registrado para ${command.type}`);
    }

    return handler.execute(command);
  }
}

// Definição do bus de queries
class QueryBus {
  private handlers: Map<string, QueryHandler<any, any>> = new Map();

  register<T extends Query, R>(
    queryType: string,
    handler: QueryHandler<T, R>
  ): void {
    this.handlers.set(queryType, handler);
  }

  async execute<T extends Query, R>(query: T): Promise<R> {
    const handler = this.handlers.get(query.type);

    if (!handler) {
      throw new Error(`Nenhum handler registrado para ${query.type}`);
    }

    return handler.execute(query);
  }
}

Exemplo de Uso Prático

// Simular um repositório em memória para exemplo
class InMemoryUserRepository implements UserRepository {
  private users: Map<string, User> = new Map();

  async save(user: User): Promise<void> {
    this.users.set(user.id, user);
  }

  async findById(id: string): Promise<User | null> {
    return this.users.get(id) || null;
  }

  async list(page: number, limit: number): Promise<User[]> {
    return Array.from(this.users.values()).slice(
      (page - 1) * limit,
      page * limit
    );
  }

  async count(): Promise<number> {
    return this.users.size;
  }
}

// Classe User simples
class User {
  constructor(
    public id: string,
    public name: string,
    public email: string
  ) {}
}

// Configuração e execução
async function main() {
  const userRepository = new InMemoryUserRepository();

  // Criar os buses
  const commandBus = new CommandBus();
  const queryBus = new QueryBus();

  // Registrar handlers
  commandBus.register('CreateUser', new CreateUserCommandHandler(userRepository));
  commandBus.register('UpdateUserEmail', new UpdateUserEmailCommandHandler(userRepository));
  queryBus.register('GetUserById', new GetUserByIdQueryHandler(userRepository));
  queryBus.register('ListUsers', new ListUsersQueryHandler(userRepository));

  // Executar um command
  const userId = await commandBus.execute(
    new CreateUserCommand('user-1', 'João Silva', 'joao@example.com')
  );
  console.log('Usuário criado:', userId);

  // Executar uma query
  const user = await queryBus.execute(
    new GetUserByIdQuery(userId)
  );
  console.log('Usuário encontrado:', user);

  // Atualizar email
  await commandBus.execute(
    new UpdateUserEmailCommand(userId, 'joao.novo@example.com')
  );
  console.log('Email atualizado');

  // Listar usuários
  const list = await queryBus.execute(
    new ListUsersQuery(1, 10)
  );
  console.log('Lista de usuários:', list);
}

main().catch(console.error);

Integração com Express e Tipagem de Ponta a Ponta

Controladores Tipados

Agora que temos o CQRS implementado, vamos integrar com um framework web como Express mantendo a tipagem forte.

import express, { Request, Response } from 'express';

interface CreateUserRequest {
  name: string;
  email: string;
}

interface ApiResponse<T> {
  success: boolean;
  data?: T;
  error?: string;
}

// Middleware de validação de request
function validateCreateUserRequest(
  req: Request,
  res: Response,
  next: Function
): void {
  const { name, email } = req.body as unknown;

  if (!name || typeof name !== 'string') {
    res.status(400).json({ success: false, error: 'Nome é obrigatório' });
    return;
  }

  if (!email || typeof email !== 'string') {
    res.status(400).json({ success: false, error: 'Email é obrigatório' });
    return;
  }

  next();
}

// Controlador de usuários
class UserController {
  constructor(
    private commandBus: CommandBus,
    private queryBus: QueryBus
  ) {}

  async createUser(req: Request, res: Response): Promise<void> {
    try {
      const { name, email } = req.body as CreateUserRequest;
      const userId = await this.commandBus.execute(
        new CreateUserCommand(
          `user-${Date.now()}`,
          name,
          email
        )
      );

      const response: ApiResponse<{ id: string }> = {
        success: true,
        data: { id: userId },
      };

      res.status(201).json(response);
    } catch (error) {
      const response: ApiResponse<null> = {
        success: false,
        error: error instanceof Error ? error.message : 'Erro desconhecido',
      };

      res.status(400).json(response);
    }
  }

  async getUser(req: Request, res: Response): Promise<void> {
    try {
      const { userId } = req.params;
      const user = await this.queryBus.execute(
        new GetUserByIdQuery(userId)
      );

      if (!user) {
        const response: ApiResponse<null> = {
          success: false,
          error: 'Usuário não encontrado',
        };

        res.status(404).json(response);
        return;
      }

      const response: ApiResponse<UserDTO> = {
        success: true,
        data: user,
      };

      res.json(response);
    } catch (error) {
      const response: ApiResponse<null> = {
        success: false,
        error: error instanceof Error ? error.message : 'Erro desconhecido',
      };

      res.status(400).json(response);
    }
  }

  async listUsers(req: Request, res: Response): Promise<void> {
    try {
      const page = Math.max(1, parseInt(req.query.page as string) || 1);
      const limit = Math.min(100, parseInt(req.query.limit as string) || 10);

      const result = await this.queryBus.execute(
        new ListUsersQuery(page, limit)
      );

      const response: ApiResponse<typeof result> = {
        success: true,
        data: result,
      };

      res.json(response);
    } catch (error) {
      const response: ApiResponse<null> = {
        success: false,
        error: error instanceof Error ? error.message : 'Erro desconhecido',
      };

      res.status(400).json(response);
    }
  }
}

// Setup do Express
function setupRoutes(
  app: express.Application,
  userRepository: UserRepository,
  commandBus: CommandBus,
  queryBus: QueryBus
): void {
  const controller = new UserController(commandBus, queryBus);

  app.post('/users', validateCreateUserRequest, (req, res) =>
    controller.createUser(req, res)
  );

  app.get('/users/:userId', (req, res) =>
    controller.getUser(req, res)
  );

  app.get('/users', (req, res) =>
    controller.listUsers(req, res)
  );
}

// Inicializar aplicação
const app = express();
app.use(express.json());

const userRepository = new InMemoryUserRepository();
const commandBus = new CommandBus();
const queryBus = new QueryBus();

commandBus.register('CreateUser', new CreateUserCommandHandler(userRepository));
commandBus.register('UpdateUserEmail', new UpdateUserEmailCommandHandler(userRepository));
queryBus.register('GetUserById', new GetUserByIdQueryHandler(userRepository));
queryBus.register('ListUsers', new ListUsersQueryHandler(userRepository));

setupRoutes(app, userRepository, commandBus, queryBus);

app.listen(3000, () => {
  console.log('Servidor rodando em http://localhost:3000');
});

Padrões Avançados e Boas Práticas

Event Sourcing e Auditoria

Um dos maiores benefícios do CQRS é a possibilidade de integração com Event Sourcing. Cada comando que altera o estado pode gerar um evento que fica registrado permanentemente, criando um histórico completo de mudanças.

// Evento de domínio
interface DomainEvent {
  type: string;
  timestamp: Date;
  aggregateId: string;
}

class UserCreatedEvent implements DomainEvent {
  type = 'UserCreated';
  timestamp = new Date();

  constructor(
    public aggregateId: string,
    public name: string,
    public email: string
  ) {}
}

class UserEmailUpdatedEvent implements DomainEvent {
  type = 'UserEmailUpdated';
  timestamp = new Date();

  constructor(
    public aggregateId: string,
    public newEmail: string
  ) {}
}

// Handler que publica eventos
class CreateUserCommandHandlerWithEvents implements CommandHandler<CreateUserCommand, string> {
  private events: DomainEvent[] = [];

  constructor(
    private userRepository: UserRepository,
    private eventBus: EventBus
  ) {}

  async execute(command: CreateUserCommand): Promise<string> {
    if (!command.email.includes('@')) {
      throw new Error('Email inválido');
    }

    const user = new User(command.id, command.name, command.email);
    await this.userRepository.save(user);

    // Publicar evento
    const event = new UserCreatedEvent(user.id, user.name, user.email);
    await this.eventBus.publish(event);

    return user.id;
  }
}

// Bus de eventos para handlers de eventos
interface EventHandler<T extends DomainEvent> {
  handle(event: T): Promise<void>;
}

class EventBus {
  private handlers: Map<string, EventHandler<any>[]> = new Map();

  subscribe<T extends DomainEvent>(
    eventType: string,
    handler: EventHandler<T>
  ): void {
    if (!this.handlers.has(eventType)) {
      this.handlers.set(eventType, []);
    }

    this.handlers.get(eventType)!.push(handler);
  }

  async publish(event: DomainEvent): Promise<void> {
    const handlers = this.handlers.get(event.type) || [];

    await Promise.all(
      handlers.map(handler => handler.handle(event))
    );
  }
}

// Um handler de evento que envia email
class SendWelcomeEmailEventHandler implements EventHandler<UserCreatedEvent> {
  async handle(event: UserCreatedEvent): Promise<void> {
    console.log(`Enviando email de boas-vindas para ${event.email}`);
    // Lógica real de envio de email aqui
  }
}

Validação e Segurança

Commands devem ser validados antes de serem executados. Uma abordagem é criar um middleware de validação genérico.

interface Validator<T> {
  validate(object: T): Promise<string[]>; // Retorna array de erros
}

class EmailValidator implements Validator<CreateUserCommand> {
  async validate(command: CreateUserCommand): Promise<string[]> {
    const errors: string[] = [];

    if (!command.email.includes('@')) {
      errors.push('Email deve conter @');
    }

    if (command.email.length < 5) {
      errors.push('Email muito curto');
    }

    return errors;
  }
}

class ValidatingCommandBus {
  private handlers: Map<string, CommandHandler<any, any>> = new Map();
  private validators: Map<string, Validator<any>[]> = new Map();

  register<T extends Command, R = void>(
    commandType: string,
    handler: CommandHandler<T, R>
  ): void {
    this.handlers.set(commandType, handler);
  }

  registerValidator<T extends Command>(
    commandType: string,
    validator: Validator<T>
  ): void {
    if (!this.validators.has(commandType)) {
      this.validators.set(commandType, []);
    }

    this.validators.get(commandType)!.push(validator);
  }

  async execute<T extends Command, R = void>(command: T): Promise<R> {
    const validators = this.validators.get(command.type) || [];
    const allErrors: string[] = [];

    for (const validator of validators) {
      const errors = await validator.validate(command);
      allErrors.push(...errors);
    }

    if (allErrors.length > 0) {
      throw new Error(`Validação falhou: ${allErrors.join(', ')}`);
    }

    const handler = this.handlers.get(command.type);

    if (!handler) {
      throw new Error(`Nenhum handler registrado para ${command.type}`);
    }

    return handler.execute(command);
  }
}

Conclusão

Durante este artigo, você aprendeu que CQRS é uma separação deliberada entre leitura e escrita que permite otimizar cada fluxo independentemente. Em TypeScript, isso se traduz em commands e queries fortemente tipados, evitando bugs em tempo de compilação. Você também viu que o bus (command e query) é o padrão central que desacopla produtores de consumidores, tornando o código mais testável e manutenível. Por fim, implementamos uma integração prática com Express, mostrando que CQRS não é apenas teoria—é um padrão aplicável imediatamente em aplicações reais, especialmente quando escalabilidade e manutenção de longo prazo são prioridades.

Referências


Artigos relacionados