DDD: Domain Events - Comunicação entre contextos (Parte 8)

DDD9 min de leitura

Este é o oitavo artigo da série sobre Domain-Driven Design. Nos artigos anteriores, exploramos os building blocks fundamentais do DDD. Agora chegou o momento de entender como Domain Events promovem comunicação desacoplada entre agregados e bounded contexts, criando arquiteturas orientadas a eventos robustas e escaláveis.

Os Domain Events são fundamentais para resolver um dos maiores desafios do DDD: como manter consistência e coordenação entre diferentes partes do domínio sem criar acoplamento direto.

O que são Domain Events?

Domain Events são fatos que aconteceram no passado e que são importantes para o domínio. Eles representam mudanças significativas no estado dos agregados que outras partes do sistema precisam conhecer e potencialmente reagir.

Como Martin Fowler define:

"Um Domain Event captura a memória de algo interessante que afeta o domínio."

Características dos Domain Events

  1. Representam o passado - Algo que já aconteceu
  2. São imutáveis - Uma vez criados, não podem ser alterados
  3. Contêm dados relevantes - Informações necessárias para os handlers
  4. Nomeados com verbos no passado - Ex: PedidoCriadoEvent, PagamentoProcessadoEvent
  5. Fazem parte da Ubiquitous Language - Especialistas do domínio os entendem

Por que Usar Domain Events?

1. Desacoplamento entre Agregados

Events permitem que agregados se comuniquem indiretamente, mantendo suas fronteiras bem definidas:

// ❌ Sem Events - Acoplamento direto
class Pedido extends AggregateRoot
{
    public function confirmar(EstoqueService $estoqueService, EmailService $emailService): void
    {
        $this->status = StatusPedido::CONFIRMADO;
        
        // Acoplamento direto com outros contextos
        foreach ($this->itens as $item) {
            $estoqueService->reservar($item->getProdutoId(), $item->getQuantidade());
        }
        
        $emailService->enviarConfirmacao($this->clienteEmail, $this->numero);
    }
}

// ✅ Com Events - Desacoplado
class Pedido extends AggregateRoot
{
    public function confirmar(): void
    {
        $this->status = StatusPedido::CONFIRMADO;
        
        // Apenas registra o evento
        $this->raiseEvent(new PedidoConfirmadoEvent(
            $this->id,
            $this->clienteId,
            $this->itens->toArray(),
            $this->valorTotal,
            new \DateTimeImmutable()
        ));
    }
}

2. Single Responsibility Principle

Events ajudam a manter agregados focados em sua responsabilidade principal:

// Handler separado para lógica de estoque
class ReservarEstoqueQuandoPedidoConfirmado
{
    public function __construct(
        private readonly EstoqueRepository $estoqueRepository
    ) {}
    
    public function handle(PedidoConfirmadoEvent $event): void
    {
        foreach ($event->getItens() as $item) {
            $estoque = $this->estoqueRepository->buscarPorProduto($item->getProdutoId());
            $estoque->reservar($item->getQuantidade());
            $this->estoqueRepository->salvar($estoque);
        }
    }
}

// Handler separado para notificações
class NotificarClienteQuandoPedidoConfirmado
{
    public function __construct(
        private readonly NotificacaoService $notificacaoService,
        private readonly ClienteRepository $clienteRepository
    ) {}
    
    public function handle(PedidoConfirmadoEvent $event): void
    {
        $cliente = $this->clienteRepository->buscar($event->getClienteId());
        
        $this->notificacaoService->enviarConfirmacaoPedido(
            $cliente->getEmail(),
            $event->getPedidoId(),
            $event->getValorTotal()
        );
    }
}

3. Extensibilidade

Novos handlers podem ser adicionados sem modificar código existente:

// Novo requisito: integração com analytics
class RegistrarAnalyticsQuandoPedidoConfirmado
{
    public function handle(PedidoConfirmadoEvent $event): void
    {
        // Enviar dados para sistema de analytics
        $this->analyticsService->track('pedido_confirmado', [
            'valor' => $event->getValorTotal()->getValor(),
            'cliente_id' => $event->getClienteId()->valor,
            'timestamp' => $event->getOcorridoEm()->format('c')
        ]);
    }
}

Types de Events: Domain vs Integration

Domain Events

São eventos que ocorrem dentro de um bounded context e são processados sincronamente na mesma transação:

class PedidoConfirmadoEvent
{
    public function __construct(
        private readonly PedidoId $pedidoId,
        private readonly ClienteId $clienteId,
        private readonly array $itens,
        private readonly Dinheiro $valorTotal,
        private readonly \DateTimeImmutable $ocorridoEm
    ) {}
    
    // Getters...
}

Integration Events

São eventos que cruzam boundaries entre bounded contexts ou microserviços e são processados assincronamente:

class PedidoConfirmadoIntegrationEvent
{
    public function __construct(
        public readonly string $pedidoId,
        public readonly string $clienteId,
        public readonly array $itens,
        public readonly float $valorTotal,
        public readonly string $ocorridoEm,
        public readonly int $versao = 1
    ) {}
    
    public function toArray(): array
    {
        return [
            'pedido_id' => $this->pedidoId,
            'cliente_id' => $this->clienteId,
            'itens' => $this->itens,
            'valor_total' => $this->valorTotal,
            'ocorrido_em' => $this->ocorridoEm,
            'versao' => $this->versao
        ];
    }
}

Implementação Prática

1. Event Base Classes

abstract class DomainEvent
{
    protected \DateTimeImmutable $ocorridoEm;
    protected EventId $eventId;
    
    public function __construct()
    {
        $this->ocorridoEm = new \DateTimeImmutable();
        $this->eventId = EventId::gerar();
    }
    
    public function getOcorridoEm(): \DateTimeImmutable
    {
        return $this->ocorridoEm;
    }
    
    public function getEventId(): EventId
    {
        return $this->eventId;
    }
}

abstract class IntegrationEvent extends DomainEvent
{
    abstract public function getEventName(): string;
    abstract public function getPayload(): array;
}

2. Aggregate Root com Events

abstract class AggregateRoot
{
    /** @var DomainEvent[] */
    private array $events = [];
    
    protected function raiseEvent(DomainEvent $event): void
    {
        $this->events[] = $event;
    }
    
    public function getUncommittedEvents(): array
    {
        return $this->events;
    }
    
    public function clearEvents(): void
    {
        $this->events = [];
    }
}

3. Event Dispatcher

interface EventDispatcher
{
    public function dispatch(DomainEvent $event): void;
    public function subscribe(string $eventType, callable $handler): void;
}

class InMemoryEventDispatcher implements EventDispatcher
{
    /** @var array<string, callable[]> */
    private array $handlers = [];
    
    public function subscribe(string $eventType, callable $handler): void
    {
        $this->handlers[$eventType][] = $handler;
    }
    
    public function dispatch(DomainEvent $event): void
    {
        $eventType = get_class($event);
        
        if (!isset($this->handlers[$eventType])) {
            return;
        }
        
        foreach ($this->handlers[$eventType] as $handler) {
            $handler($event);
        }
    }
}

4. Unit of Work com Events

class UnitOfWork
{
    /** @var AggregateRoot[] */
    private array $aggregates = [];
    
    public function __construct(
        private readonly EventDispatcher $eventDispatcher,
        private readonly \PDO $pdo
    ) {}
    
    public function register(AggregateRoot $aggregate): void
    {
        $this->aggregates[] = $aggregate;
    }
    
    public function commit(): void
    {
        $this->pdo->beginTransaction();
        
        try {
            // 1. Salvar agregados
            foreach ($this->aggregates as $aggregate) {
                $this->persist($aggregate);
            }
            
            // 2. Despachar events após persistir
            $this->dispatchEvents();
            
            $this->pdo->commit();
            
            // 3. Limpar events dos agregados
            $this->clearAggregateEvents();
            
        } catch (\Exception $e) {
            $this->pdo->rollback();
            throw $e;
        }
    }
    
    private function dispatchEvents(): void
    {
        foreach ($this->aggregates as $aggregate) {
            foreach ($aggregate->getUncommittedEvents() as $event) {
                $this->eventDispatcher->dispatch($event);
            }
        }
    }
    
    private function clearAggregateEvents(): void
    {
        foreach ($this->aggregates as $aggregate) {
            $aggregate->clearEvents();
        }
    }
}

Padrões de Implementação

1. Immediate Dispatch

Events são despachados imediatamente quando raised:

class ImmediateEventDispatcher implements EventDispatcher
{
    public function dispatch(DomainEvent $event): void
    {
        // Processa imediatamente
        foreach ($this->getHandlers(get_class($event)) as $handler) {
            $handler($event);
        }
    }
}

Prós:

  • Simples de implementar
  • Feedback imediato de erros

Contras:

  • Efeitos colaterais executam na mesma transação
  • Pode causar performance issues

2. Deferred Dispatch

Events são coletados e despachados depois da persistência:

class DeferredEventDispatcher implements EventDispatcher
{
    private array $deferredEvents = [];
    
    public function defer(DomainEvent $event): void
    {
        $this->deferredEvents[] = $event;
    }
    
    public function flushEvents(): void
    {
        foreach ($this->deferredEvents as $event) {
            $this->dispatch($event);
        }
        
        $this->deferredEvents = [];
    }
}

Prós:

  • Events só são despachados se a transação principal for bem-sucedida
  • Melhor performance

Contras:

  • Mais complexo
  • Handlers podem falhar após commit

3. Event Store

Events são persistidos e processados posteriormente:

interface EventStore
{
    public function append(string $streamId, DomainEvent $event): void;
    public function getEvents(string $streamId): array;
}

class DatabaseEventStore implements EventStore
{
    public function append(string $streamId, DomainEvent $event): void
    {
        $sql = "INSERT INTO events (stream_id, event_type, event_data, occurred_at) 
                VALUES (?, ?, ?, ?)";
        
        $this->pdo->prepare($sql)->execute([
            $streamId,
            get_class($event),
            json_encode($event),
            $event->getOcorridoEm()->format('Y-m-d H:i:s')
        ]);
    }
}

Integration Events

Para comunicação entre bounded contexts:

1. Event Publisher

interface IntegrationEventPublisher
{
    public function publish(IntegrationEvent $event): void;
}

class RabbitMQIntegrationEventPublisher implements IntegrationEventPublisher
{
    public function __construct(
        private readonly AMQPChannel $channel
    ) {}
    
    public function publish(IntegrationEvent $event): void
    {
        $message = new AMQPMessage(
            json_encode($event->getPayload()),
            ['content_type' => 'application/json']
        );
        
        $this->channel->basic_publish(
            $message,
            'integration_events',
            $event->getEventName()
        );
    }
}

2. Event Handler Bridge

Converte Domain Events em Integration Events:

class PublicarIntegrationEventQuandoPedidoConfirmado
{
    public function __construct(
        private readonly IntegrationEventPublisher $publisher
    ) {}
    
    public function handle(PedidoConfirmadoEvent $domainEvent): void
    {
        $integrationEvent = new PedidoConfirmadoIntegrationEvent(
            $domainEvent->getPedidoId()->valor,
            $domainEvent->getClienteId()->valor,
            $this->mapearItens($domainEvent->getItens()),
            $domainEvent->getValorTotal()->getValor(),
            $domainEvent->getOcorridoEm()->format('c')
        );
        
        $this->publisher->publish($integrationEvent);
    }
}

Event Versioning

Integration events precisam de estratégias de versionamento:

1. Additive Changes

// V1
class PedidoConfirmadoIntegrationEvent
{
    public function __construct(
        public readonly string $pedidoId,
        public readonly string $clienteId,
        public readonly float $valorTotal
    ) {}
}

// V2 - Adicionando campo (backward compatible)
class PedidoConfirmadoIntegrationEvent
{
    public function __construct(
        public readonly string $pedidoId,
        public readonly string $clienteId,
        public readonly float $valorTotal,
        public readonly ?string $cupomDesconto = null // Novo campo opcional
    ) {}
}

2. Breaking Changes

class PedidoConfirmadoIntegrationEventV2
{
    public function __construct(
        public readonly string $pedidoId,
        public readonly string $clienteId,
        public readonly array $valorTotal, // Mudança breaking - agora é objeto com moeda
        public readonly int $versao = 2
    ) {}
}

Testes

1. Testando Events

class PedidoTest extends TestCase
{
    public function test_deve_raise_event_quando_pedido_confirmado(): void
    {
        // Arrange
        $pedido = new Pedido($this->criarPedidoValido());
        
        // Act
        $pedido->confirmar();
        
        // Assert
        $events = $pedido->getUncommittedEvents();
        $this->assertCount(1, $events);
        $this->assertInstanceOf(PedidoConfirmadoEvent::class, $events[0]);
        
        $event = $events[0];
        $this->assertEquals($pedido->getId(), $event->getPedidoId());
    }
}

2. Testando Handlers

class ReservarEstoqueQuandoPedidoConfirmadoTest extends TestCase
{
    public function test_deve_reservar_estoque_quando_pedido_confirmado(): void
    {
        // Arrange
        $estoqueRepository = $this->createMock(EstoqueRepository::class);
        $estoque = $this->createMock(Estoque::class);
        
        $estoqueRepository
            ->expects($this->once())
            ->method('buscarPorProduto')
            ->willReturn($estoque);
            
        $estoque
            ->expects($this->once())
            ->method('reservar')
            ->with(5);
        
        $handler = new ReservarEstoqueQuandoPedidoConfirmado($estoqueRepository);
        
        $event = new PedidoConfirmadoEvent(
            PedidoId::fromString('123'),
            ClienteId::fromString('456'),
            [['produto_id' => 'abc', 'quantidade' => 5]],
            Dinheiro::fromFloat(100.0),
            new \DateTimeImmutable()
        );
        
        // Act
        $handler->handle($event);
        
        // Assert - Mocks fazem as assertions
    }
}

Boas Práticas

1. Naming

  • Domain Events: SomethingHappenedEvent (verbo no passado)
  • Integration Events: SomethingHappenedIntegrationEvent

2. Event Data

  • Inclua apenas dados necessários para os handlers
  • Evite objetos complexos - prefira IDs e valores primitivos
  • Para Integration Events, use DTOs serializáveis

3. Error Handling

class RobustEventDispatcher implements EventDispatcher
{
    public function dispatch(DomainEvent $event): void
    {
        foreach ($this->getHandlers(get_class($event)) as $handler) {
            try {
                $handler($event);
            } catch (\Exception $e) {
                $this->logger->error('Event handler failed', [
                    'event' => get_class($event),
                    'handler' => get_class($handler),
                    'error' => $e->getMessage()
                ]);
                
                // Decidir se deve continuar ou parar
                if ($this->isRetryable($e)) {
                    $this->scheduleRetry($event, $handler);
                }
            }
        }
    }
}

4. Performance

// Use async processing para Integration Events
class AsyncIntegrationEventPublisher implements IntegrationEventPublisher
{
    public function publish(IntegrationEvent $event): void
    {
        // Queue para processamento em background
        $this->queue->push(new PublishIntegrationEventJob($event));
    }
}

Quando NÃO Usar Events

  1. Para mudanças simples que não interessam a outros agregados
  2. Quando performance é crítica e não há necessidade de desacoplamento
  3. Em sistemas pequenos onde o overhead não compensa
  4. Para comunicação síncrona obrigatória entre agregados

Conclusão

Domain Events são uma ferramenta poderosa para:

  • Desacoplar agregados mantendo consistência
  • Implementar side effects de forma elegante
  • Criar arquiteturas extensíveis e testáveis
  • Facilitar integração entre bounded contexts

A chave está em entender quando usar Domain Events vs Integration Events e escolher a estratégia de dispatch adequada para cada contexto.

No próximo artigo, exploraremos Context Mapping, entendendo como mapear e gerenciar relacionamentos entre bounded contexts.


Referências

  • Evans, Eric. "Domain-Driven Design: Tackling Complexity in the Heart of Software"
  • Vernon, Vaughn. "Implementing Domain-Driven Design"
  • Fowler, Martin. "Domain Event" - martinfowler.com
  • Young, Greg. "What is a Domain Event?" - codebetter.com
  • Bogard, Jimmy. "A better domain events pattern" - lostechies.com
  • Microsoft. "Domain events: Design and implementation" - docs.microsoft.com