Event-Driven Architecture com TypeScript: Events e Subscribers Tipados na Prática Já leu

O Que é Event-Driven Architecture? A Event-Driven Architecture é um padrão arquitetural onde componentes de um sistema se comunicam através de eventos em vez de chamadas diretas. Um evento representa algo que aconteceu no sistema — uma ação concluída, uma mudança de estado ou uma ocorrência significativa. Esse padrão promove desacoplamento entre componentes, permitindo que diferentes partes da aplicação reajam a eventos sem conhecer diretamente quem os dispara. Em uma aplicação tradicional com arquitetura em camadas, você chamaria métodos diretamente: um serviço chama outro serviço, que chama outro. Isso cria dependências rígidas e torna o sistema difícil de manter e escalar. Na Event-Driven Architecture, quando algo importante acontece, um evento é emitido. Qualquer componente interessado nesse evento se registra como um "ouvinte" (subscriber) e é notificado quando o evento ocorre. Isso permite que você adicione novos comportamentos sem modificar o código existente — princípio conhecido como Open/Closed Principle. Construindo um Sistema de Events e Subscribers Tipado A Importância da Tipagem

O Que é Event-Driven Architecture?

A Event-Driven Architecture é um padrão arquitetural onde componentes de um sistema se comunicam através de eventos em vez de chamadas diretas. Um evento representa algo que aconteceu no sistema — uma ação concluída, uma mudança de estado ou uma ocorrência significativa. Esse padrão promove desacoplamento entre componentes, permitindo que diferentes partes da aplicação reajam a eventos sem conhecer diretamente quem os dispara.

Em uma aplicação tradicional com arquitetura em camadas, você chamaria métodos diretamente: um serviço chama outro serviço, que chama outro. Isso cria dependências rígidas e torna o sistema difícil de manter e escalar. Na Event-Driven Architecture, quando algo importante acontece, um evento é emitido. Qualquer componente interessado nesse evento se registra como um "ouvinte" (subscriber) e é notificado quando o evento ocorre. Isso permite que você adicione novos comportamentos sem modificar o código existente — princípio conhecido como Open/Closed Principle.

Construindo um Sistema de Events e Subscribers Tipado

A Importância da Tipagem Forte

TypeScript nos permite criar um sistema de eventos totalmente tipado, eliminando erros em tempo de execução. Quando você define tipos para eventos e seus dados associados, o compilador garante que apenas dados corretos sejam passados e que os subscribers tratem os dados adequadamente. Isso é especialmente crítico em sistemas grandes onde eventos trafegam por múltiplos componentes.

Vamos construir uma infraestrutura robusta. Primeiro, definiremos tipos genéricos que permitem criar qualquer tipo de evento mantendo a segurança de tipos:

// types/events.ts
export interface EventMap {
  [key: string]: any;
}

export interface Event<T = any> {
  type: string;
  payload: T;
  timestamp: number;
}

export type EventHandler<T = any> = (event: Event<T>) => void | Promise<void>;

export interface EventEmitter<T extends EventMap = EventMap> {
  on<K extends keyof T>(type: K, handler: EventHandler<T[K]>): void;
  off<K extends keyof T>(type: K, handler: EventHandler<T[K]>): void;
  emit<K extends keyof T>(type: K, payload: T[K]): Promise<void>;
}

Esses tipos servem como contrato base para qualquer sistema de eventos. O EventMap é um mapa que associa nomes de eventos a seus tipos de dados. Um subscriber sabe exatamente qual tipo de dado esperar, e o compilador avisa se você tentar usar um tipo incorreto.

Implementação do Event Bus

O Event Bus é o componente central que gerencia todos os eventos e subscribers. Ele mantém um registro de quem está interessado em cada tipo de evento e é responsável por distribuir os eventos aos subscribers corretos:

// infrastructure/EventBus.ts
import { Event, EventHandler, EventEmitter, EventMap } from '../types/events';

export class EventBus<T extends EventMap = EventMap> implements EventEmitter<T> {
  private handlers: Map<keyof T, Set<EventHandler<any>>> = new Map();

  on<K extends keyof T>(type: K, handler: EventHandler<T[K]>): void {
    if (!this.handlers.has(type)) {
      this.handlers.set(type, new Set());
    }
    this.handlers.get(type)!.add(handler);
  }

  off<K extends keyof T>(type: K, handler: EventHandler<T[K]>): void {
    const handlers = this.handlers.get(type);
    if (handlers) {
      handlers.delete(handler);
    }
  }

  async emit<K extends keyof T>(type: K, payload: T[K]): Promise<void> {
    const event: Event<T[K]> = {
      type: String(type),
      payload,
      timestamp: Date.now(),
    };

    const handlers = this.handlers.get(type);
    if (!handlers) return;

    const promises = Array.from(handlers).map(handler => 
      Promise.resolve(handler(event)).catch(error => {
        console.error(`Error in handler for event ${String(type)}:`, error);
      })
    );

    await Promise.all(promises);
  }
}

Note como emit é assíncrono e aguarda todas as handlers. Isso garante que todos os subscribers processem o evento antes de continuar, permitindo coordenação entre múltiplos componentes.

Definindo Eventos da Aplicação

Cada aplicação tem seus próprios eventos. Vamos criar um exemplo de um sistema de e-commerce onde eventos significativos acontecem:

// domain/events/ApplicationEvents.ts
import { EventMap } from '../../types/events';

export interface UserRegisteredPayload {
  userId: string;
  email: string;
  name: string;
  registeredAt: Date;
}

export interface OrderCreatedPayload {
  orderId: string;
  userId: string;
  items: Array<{ productId: string; quantity: number; price: number }>;
  totalAmount: number;
}

export interface OrderShippedPayload {
  orderId: string;
  trackingNumber: string;
  estimatedDelivery: Date;
}

export interface ApplicationEventMap extends EventMap {
  'user:registered': UserRegisteredPayload;
  'order:created': OrderCreatedPayload;
  'order:shipped': OrderShippedPayload;
}

Essa estrutura define exatamente quais eventos sua aplicação suporta e que dados cada um carrega. Se você tentar emitir um evento que não existe ou com dados do tipo errado, TypeScript avisa imediatamente.

Criando Subscribers Tipados

Padrão de Subscriber

Um subscriber é simplesmente uma classe que se registra no Event Bus e reage a eventos específicos. O padrão que vamos usar permite que cada subscriber seja independente e fácil de testar:

// domain/subscribers/SendWelcomeEmailSubscriber.ts
import { Event, EventHandler } from '../../types/events';
import { UserRegisteredPayload } from '../events/ApplicationEvents';

export class SendWelcomeEmailSubscriber {
  async handle(event: Event<UserRegisteredPayload>): Promise<void> {
    const { email, name } = event.payload;

    // Simula envio de email
    console.log(`Sending welcome email to ${email} for ${name}`);

    // Em produção, você chamaria um serviço de email real
    await this.sendEmail(email, `Welcome, ${name}!`);
  }

  private async sendEmail(to: string, subject: string): Promise<void> {
    // Implementação real de envio de email
    return Promise.resolve();
  }
}

Um subscriber que precisa fazer algo quando uma ordem é criada:

// domain/subscribers/UpdateInventorySubscriber.ts
import { Event } from '../../types/events';
import { OrderCreatedPayload } from '../events/ApplicationEvents';

export class UpdateInventorySubscriber {
  async handle(event: Event<OrderCreatedPayload>): Promise<void> {
    const { items } = event.payload;

    console.log(`Updating inventory for ${items.length} items`);

    for (const item of items) {
      await this.decrementStock(item.productId, item.quantity);
    }
  }

  private async decrementStock(productId: string, quantity: number): Promise<void> {
    // Chama banco de dados para decrementar estoque
    console.log(`Decrementing ${quantity} units of product ${productId}`);
  }
}

Registrando Subscribers no Event Bus

A forma como você registra subscribers define como sua aplicação reage a eventos. Vamos criar um bootstrapper que centraliza esse registro:

// infrastructure/SubscriberRegistry.ts
import { EventBus } from './EventBus';
import { ApplicationEventMap } from '../domain/events/ApplicationEvents';
import { SendWelcomeEmailSubscriber } from '../domain/subscribers/SendWelcomeEmailSubscriber';
import { UpdateInventorySubscriber } from '../domain/subscribers/UpdateInventorySubscriber';

export function registerSubscribers(eventBus: EventBus<ApplicationEventMap>): void {
  const sendWelcomeEmailSubscriber = new SendWelcomeEmailSubscriber();
  const updateInventorySubscriber = new UpdateInventorySubscriber();

  // Registra subscribers para eventos específicos
  eventBus.on('user:registered', (event) => sendWelcomeEmailSubscriber.handle(event));
  eventBus.on('order:created', (event) => updateInventorySubscriber.handle(event));
}

Quando um evento é emitido, todas as handlers registradas são chamadas. A tipagem garante que você não possa registrar uma handler para um evento inexistente ou tentar registrar uma handler que espera um tipo de dados diferente.

Integrando Events com a Aplicação Real

Exemplo Prático: Fluxo Completo de Pedido

Vamos ver como tudo funciona junto em um caso de uso real — criar um pedido:

// application/services/OrderService.ts
import { EventBus } from '../../infrastructure/EventBus';
import { ApplicationEventMap, OrderCreatedPayload } from '../../domain/events/ApplicationEvents';

export class OrderService {
  constructor(private eventBus: EventBus<ApplicationEventMap>) {}

  async createOrder(
    userId: string,
    items: Array<{ productId: string; quantity: number; price: number }>
  ): Promise<string> {
    // Validações e lógica de negócio
    const orderId = this.generateOrderId();
    const totalAmount = items.reduce((sum, item) => sum + item.price * item.quantity, 0);

    // Persiste a ordem no banco de dados
    await this.saveOrderToDatabase({
      id: orderId,
      userId,
      items,
      totalAmount,
      status: 'created',
    });

    // Emite o evento para que subscribers reajam
    const payload: OrderCreatedPayload = {
      orderId,
      userId,
      items,
      totalAmount,
    };

    await this.eventBus.emit('order:created', payload);

    return orderId;
  }

  private generateOrderId(): string {
    return `ORD-${Date.now()}`;
  }

  private async saveOrderToDatabase(order: any): Promise<void> {
    console.log('Saving order to database:', order);
  }
}

Agora vamos usar esse serviço:

// main.ts
import { EventBus } from './infrastructure/EventBus';
import { OrderService } from './application/services/OrderService';
import { registerSubscribers } from './infrastructure/SubscriberRegistry';
import { ApplicationEventMap } from './domain/events/ApplicationEvents';

async function main() {
  const eventBus = new EventBus<ApplicationEventMap>();

  // Registra todos os subscribers
  registerSubscribers(eventBus);

  const orderService = new OrderService(eventBus);

  // Simula criação de pedido
  const orderId = await orderService.createOrder('user-123', [
    { productId: 'prod-1', quantity: 2, price: 29.99 },
    { productId: 'prod-2', quantity: 1, price: 49.99 },
  ]);

  console.log(`Order created: ${orderId}`);
}

main().catch(console.error);

Quando createOrder é executado, ele emite o evento order:created. Automaticamente, sem que o OrderService saiba, o UpdateInventorySubscriber é acionado e decrementa o estoque. Você pode adicionar mais subscribers (como enviar notificação ao cliente, gerar nota fiscal, etc.) sem modificar o OrderService.

Vantagens Desse Padrão

Este padrão oferece several benefícios tangíveis. Primeiro, desacoplamento real: o OrderService não importa ou depende de nenhum subscriber. Segundo, facilidade de teste: você pode testar o OrderService sem precisar de nenhum subscriber registrado, ou testar cada subscriber independentemente. Terceiro, extensibilidade: adicionar novos comportamentos é apenas questão de criar um novo subscriber e registrá-lo — nenhuma mudança no código existente.

Padrões Avançados e Boas Práticas

Tratamento de Erros em Eventos Assíncronos

Em sistemas reais, handlers podem falhar. É importante não deixar uma falha em um subscriber impedir que outros subscribers executem:

// infrastructure/EventBus.ts (versão melhorada)
export class EventBus<T extends EventMap = EventMap> implements EventEmitter<T> {
  private handlers: Map<keyof T, Set<EventHandler<any>>> = new Map();
  private errorHandler?: (error: Error, eventType: string) => void;

  setErrorHandler(handler: (error: Error, eventType: string) => void): void {
    this.errorHandler = handler;
  }

  async emit<K extends keyof T>(type: K, payload: T[K]): Promise<void> {
    const event: Event<T[K]> = {
      type: String(type),
      payload,
      timestamp: Date.now(),
    };

    const handlers = this.handlers.get(type);
    if (!handlers) return;

    // Executa todas as handlers em paralelo, mas captura erros individualmente
    const promises = Array.from(handlers).map(handler =>
      Promise.resolve(handler(event))
        .catch(error => {
          if (this.errorHandler) {
            this.errorHandler(error, String(type));
          } else {
            console.error(`Error in handler for event ${String(type)}:`, error);
          }
        })
    );

    await Promise.all(promises);
  }

  // ... resto do código
}

Eventos Prioritários

Às vezes, certos handlers devem executar antes de outras. Vamos adicionar suporte a prioridade:

// infrastructure/EventBus.ts (com suporte a prioridade)
interface HandlerEntry<T> {
  handler: EventHandler<T>;
  priority: number; // Maior número = maior prioridade
}

export class EventBus<T extends EventMap = EventMap> implements EventEmitter<T> {
  private handlers: Map<keyof T, HandlerEntry<any>[]> = new Map();

  on<K extends keyof T>(
    type: K,
    handler: EventHandler<T[K]>,
    priority: number = 0
  ): void {
    if (!this.handlers.has(type)) {
      this.handlers.set(type, []);
    }

    const handlersList = this.handlers.get(type)!;
    handlersList.push({ handler, priority });

    // Ordena por prioridade (decrescente)
    handlersList.sort((a, b) => b.priority - a.priority);
  }

  async emit<K extends keyof T>(type: K, payload: T[K]): Promise<void> {
    const event: Event<T[K]> = {
      type: String(type),
      payload,
      timestamp: Date.now(),
    };

    const handlersList = this.handlers.get(type);
    if (!handlersList) return;

    // Executa handlers em ordem de prioridade
    for (const { handler } of handlersList) {
      try {
        await Promise.resolve(handler(event));
      } catch (error) {
        console.error(`Error in handler for event ${String(type)}:`, error);
      }
    }
  }
}

Middleware para Events

Você pode adicionar camadas de processamento antes e depois dos handlers — útil para logging, autenticação, ou transformação de dados:

// infrastructure/EventMiddleware.ts
import { Event } from '../types/events';

export interface EventMiddleware {
  before?(event: Event): Promise<void>;
  after?(event: Event, result: any): Promise<void>;
}

export class EventBusWithMiddleware<T extends EventMap = EventMap> extends EventBus<T> {
  private middlewares: EventMiddleware[] = [];

  use(middleware: EventMiddleware): void {
    this.middlewares.push(middleware);
  }

  async emit<K extends keyof T>(type: K, payload: T[K]): Promise<void> {
    const event: Event<T[K]> = {
      type: String(type),
      payload,
      timestamp: Date.now(),
    };

    // Executa middlewares "before"
    for (const middleware of this.middlewares) {
      if (middleware.before) {
        await middleware.before(event);
      }
    }

    // Executa handlers normais
    await super.emit(type, payload);

    // Executa middlewares "after"
    for (const middleware of this.middlewares) {
      if (middleware.after) {
        await middleware.after(event, undefined);
      }
    }
  }
}

// Exemplo de middleware de logging
const loggingMiddleware: EventMiddleware = {
  before: async (event) => {
    console.log(`[EVENT] ${event.type} emitted at ${new Date(event.timestamp).toISOString()}`);
  },
  after: async (event) => {
    console.log(`[EVENT] ${event.type} processed`);
  },
};

Conclusão

Você aprendeu três conceitos fundamentais que transformarão a forma como você arquiteta aplicações TypeScript. Primeiro, Event-Driven Architecture desacopla componentes, permitindo que diferentes partes do sistema reajam a eventos sem conhecer diretamente quem os emite — isso torna o código mais flexível, testável e manutenível. Segundo, tipagem forte em TypeScript garante segurança em tempo de compilação, impedindo erros onde você tenta passar dados incorretos para um evento ou handler. Terceiro, padrões como prioridade e middleware adicionam poder ao sistema sem complexidade desnecessária — comece simples e evolua conforme suas necessidades crescem.

A chave para sucesso com Event-Driven Architecture é começar com uma base sólida de tipos e infraestrutura, depois deixar que a natureza desacoplada do padrão trabalhe a seu favor conforme o sistema cresce.

Referências


Artigos relacionados