DEV Community

Thiago Souza
Thiago Souza

Posted on

Domain Events: Transformando Mudanças em Oportunidades

Introdução

E se seu código pudesse anunciar quando algo importante acontece, ao invés de você ter que conectar manualmente todos os sistemas interessados? E se adicionar novos comportamentos não exigisse modificar código existente?

Bem-vindo ao mundo dos Domain Events!

O Problema: Acoplamento em Cascata

Imagine que você precisa implementar: "Quando um cliente for aprovado, envie email e notifique vendas".

Abordagem Ingênua (acoplada)

@Service
public class CustomerService {
    @Autowired private EmailService emailService;
    @Autowired private SalesNotificationService salesService;

    public void approveCustomer(UUID customerId) {
        Customer customer = repository.findById(customerId);
        customer.setStatus(APPROVED);
        repository.save(customer);

        emailService.sendApprovalEmail(customer);
        salesService.notifySalesTeam(customer);
    }
}
Enter fullscreen mode Exit fullscreen mode

Problemas:

  1. Acoplamento direto: Service conhece EmailService e SalesNotificationService
  2. Violação SRP: Aprovar cliente + enviar email + notificar vendas = 3 responsabilidades
  3. Difícil testar: Precisa mockar todos os serviços
  4. Rigidez: Adicionar novo comportamento = modificar código existente
  5. Falha em cascata: Se email falhar, aprovação falha também

Agora imagine adicionar:

  • Enviar SMS
  • Atualizar analytics
  • Notificar sistema externo
  • Gerar evento no data warehouse
  • Criar task no CRM

Seu approveCustomer() vira um monstro de 50 linhas com 10 dependências!

Domain Events: A Solução Elegante

Abordagem com Domain Events

public class Customer extends AggregateRoot {
    public void approve() {
        if (this.status != CustomerStatus.PENDING) {
            throw new IllegalArgumentException("Customer status is not pending");
        }

        this.status = CustomerStatus.APPROVED;
        this.updatedAt = Instant.now();

        // 🎯 Apenas anuncia o que aconteceu!
        this.recordDomainEvent(new CustomerApproved(this.id()));
    }
}
Enter fullscreen mode Exit fullscreen mode

Vantagens:

  • Zero acoplamento: Domínio não conhece quem vai reagir
  • SRP preservado: Apenas muda estado e anuncia
  • Fácil testar: Não precisa mockar nada
  • Open/Closed: Adicione listeners sem modificar o domínio
  • Isolamento de falhas: Listener falha, aggregate não

Anatomia de um Domain Event

Interface Base

package com.github.thrsouza.sauron.domain;

public interface DomainEvent {
    UUID eventId();            // Identificador único do evento
    String eventType();        // Tipo/nome do evento
    Instant eventOccurredAt(); // Quando aconteceu
}
Enter fullscreen mode Exit fullscreen mode

Design decisions:

  • eventId: Rastreabilidade e idempotência
  • eventType: Roteamento (nome do tópico Kafka)
  • eventOccurredAt: Auditoria e ordenação temporal

Implementação com Records

package com.github.thrsouza.sauron.domain.customer.events;

public record CustomerCreated(
    UUID eventId,
    UUID customerId,
    Instant eventOccurredAt
) implements DomainEvent {

    // Construtor conveniente
    public CustomerCreated(UUID customerId) {
        this(UUID.randomUUID(), customerId, Instant.now());
    }

    @Override
    public String eventType() {
        return "sauron.customer-created";
    }
}
Enter fullscreen mode Exit fullscreen mode

Por que Records?

  • Imutáveis por design: Eventos nunca mudam
  • Value semantics: equals() e hashCode() automáticos
  • Serializáveis: JSON de graça com Jackson
  • Legíveis: Sintaxe concisa e clara

Família de Eventos

// Evento de criação
public record CustomerCreated(UUID eventId, UUID customerId, Instant eventOccurredAt) 
    implements DomainEvent {

    public CustomerCreated(UUID customerId) {
        this(UUID.randomUUID(), customerId, Instant.now());
    }

    @Override
    public String eventType() {
        return "sauron.customer-created";
    }
}

// Evento de aprovação
public record CustomerApproved(UUID eventId, UUID customerId, Instant eventOccurredAt) 
    implements DomainEvent {

    public CustomerApproved(UUID customerId) {
        this(UUID.randomUUID(), customerId, Instant.now());
    }

    @Override
    public String eventType() {
        return "sauron.customer-approved";
    }
}

// Evento de rejeição
public record CustomerRejected(UUID eventId, UUID customerId, Instant eventOccurredAt) 
    implements DomainEvent {

    public CustomerRejected(UUID customerId) {
        this(UUID.randomUUID(), customerId, Instant.now());
    }

    @Override
    public String eventType() {
        return "sauron.customer-rejected";
    }
}
Enter fullscreen mode Exit fullscreen mode

Padrão consistente:

  • Mesmo construtor conveniente
  • Mesmo formato de eventType
  • Payloads mínimos (apenas IDs)

Gerando Eventos no Aggregate

AggregateRoot Base Class

package com.github.thrsouza.sauron.domain;

public abstract class AggregateRoot {
    private final transient List<DomainEvent> domainEvents = new ArrayList<>();

    protected void recordDomainEvent(DomainEvent domainEvent) {
        this.domainEvents.add(domainEvent);
    }

    public List<DomainEvent> pullDomainEvents() {
        List<DomainEvent> copyOfDomainEvents = List.copyOf(this.domainEvents);
        this.domainEvents.clear();
        return copyOfDomainEvents;
    }
}
Enter fullscreen mode Exit fullscreen mode

Design patterns aplicados:

  1. Transient: Eventos não são persistidos com o aggregate
  2. Protected: Só o aggregate pode registrar eventos
  3. Pull pattern: Quem persiste o aggregate puxa os eventos
  4. Defensive copying: List.copyOf() retorna lista imutável
  5. Clear after pull: Evita publicação duplicada

Registrando Eventos

public class Customer extends AggregateRoot {

    public static Customer create(String document, String name, String email) {
        UUID id = UUID.randomUUID();
        Instant now = Instant.now();

        Customer customer = new Customer(id, document, name, email, 
                                         CustomerStatus.PENDING, now, now);

        // 🎯 Registra evento de criação
        customer.recordDomainEvent(new CustomerCreated(customer.id()));

        return customer;
    }

    public void approve() {
        validateCanApprove();

        this.status = CustomerStatus.APPROVED;
        this.updatedAt = Instant.now();

        // 🎯 Registra evento de aprovação
        this.recordDomainEvent(new CustomerApproved(this.id()));
    }

    public void reject() {
        validateCanReject();

        this.status = CustomerStatus.REJECTED;
        this.updatedAt = Instant.now();

        // 🎯 Registra evento de rejeição
        this.recordDomainEvent(new CustomerRejected(this.id()));
    }
}
Enter fullscreen mode Exit fullscreen mode

Observe:

  • Eventos registrados depois da mudança de estado
  • Eventos descrevem o que aconteceu (passado)
  • Nenhuma lógica de negócio nos eventos

Publicando Eventos

No Use Case

public class CreateCustomerUseCase {
    private final CustomerRepository customerRepository;
    private final DomainEventPublisher domainEventPublisher;

    public Output handle(Input input) {
        // Cria o aggregate
        Customer customer = Customer.create(input.document(), input.name(), input.email());

        // Persiste
        customerRepository.save(customer);

        // 🎯 Recupera e publica eventos
        domainEventPublisher.publishAll(customer.pullDomainEvents());

        return new Output(customer.id());
    }
}
Enter fullscreen mode Exit fullscreen mode

Padrão "Pull and Publish":

  1. Aggregate gera eventos durante operações
  2. Use case recupera eventos do aggregate
  3. Use case delega publicação ao publisher
  4. Publisher envia para infraestrutura (Kafka, RabbitMQ, etc.)

DomainEventPublisher (Interface)

package com.github.thrsouza.sauron.domain;

public interface DomainEventPublisher {
    void publishAll(Collection<DomainEvent> events);
}
Enter fullscreen mode Exit fullscreen mode

Interface no domínio, implementação na infraestrutura!

Implementação Kafka

@Component
public class DomainEventPublisherAdapter implements DomainEventPublisher {
    private final KafkaTemplate<String, Object> kafkaTemplate;

    @Override
    public void publishAll(Collection<DomainEvent> events) {
        events.forEach(this::publish);
    }

    private void publish(DomainEvent event) {
        String topic = event.eventType(); // Nome do tópico vem do evento!

        kafkaTemplate.send(topic, event)
            .whenComplete((result, exception) -> {
                if (exception != null) {
                    log.error("❌ Failed to publish {} to topic {}", 
                             event.getClass().getSimpleName(), topic, exception);
                } else {
                    log.info("📤 Published {} to topic {} (partition: {}, offset: {})",
                            event.getClass().getSimpleName(),
                            topic,
                            result.getRecordMetadata().partition(),
                            result.getRecordMetadata().offset());
                }
            });
    }
}
Enter fullscreen mode Exit fullscreen mode

Observabilidade:

  • Logs estruturados para debug
  • Informações de partição e offset
  • Tratamento de erros centralizado

Consumindo Eventos

Event Listeners

@Component
public class CustomerEventListener {
    private final EvaluateCustomerUseCase evaluateCustomerUseCase;

    @KafkaListener(topics = "sauron.customer-created", 
                   groupId = "${spring.kafka.consumer.group-id}")
    public void handleCustomerCreated(@Payload CustomerCreated event) {
        log.info("📥 Received CustomerCreated event - CustomerId: {}", 
                 event.customerId());

        try {
            evaluateCustomerUseCase.handle(new Input(event.customerId()));
            log.info("✅ Successfully processed CustomerCreated - CustomerId: {}", 
                     event.customerId());
        } catch (Exception e) {
            log.error("❌ Error processing CustomerCreated - CustomerId: {}", 
                      event.customerId(), e);
            throw e; // Relança para retry
        }
    }

    @KafkaListener(topics = "sauron.customer-approved", ...)
    public void handleCustomerApproved(@Payload CustomerApproved event) {
        log.info("📥 Received CustomerApproved - CustomerId: {}", 
                 event.customerId());
        // Aqui: enviar email, notificar vendas, etc.
    }

    @KafkaListener(topics = "sauron.customer-rejected", ...)
    public void handleCustomerRejected(@Payload CustomerRejected event) {
        log.info("📥 Received CustomerRejected - CustomerId: {}", 
                 event.customerId());
        // Aqui: email de rejeição, analytics, etc.
    }
}
Enter fullscreen mode Exit fullscreen mode

Benefícios Reais

1. Auditoria de Graça

Todos os eventos são logados e persistidos no Kafka:

# Lista eventos de um cliente
kafka-console-consumer --bootstrap-server localhost:9092 \
  --topic sauron.customer-created --from-beginning | grep "customerId: 123"

kafka-console-consumer --bootstrap-server localhost:9092 \
  --topic sauron.customer-approved --from-beginning | grep "customerId: 123"
Enter fullscreen mode Exit fullscreen mode

Histórico completo do que aconteceu, quando e por quê!

2. Rastreabilidade Distribuída

1. [14:23:45.123] CustomerCreated published - eventId: abc-123
2. [14:23:45.150] CustomerCreated received - eventId: abc-123
3. [14:23:50.200] CustomerApproved published - eventId: def-456
4. [14:23:50.220] CustomerApproved received - eventId: def-456
Enter fullscreen mode Exit fullscreen mode

Correlação de eventos em sistemas distribuídos!

3. Extensibilidade Sem Modificação

Antes (sem eventos):

// Para adicionar analytics, modifica o código existente
public void approveCustomer(UUID customerId) {
    // ... código existente
    emailService.send(...);
    salesService.notify(...);
    analyticsService.track(...); // ← Nova linha
}
Enter fullscreen mode Exit fullscreen mode

Depois (com eventos):

// Código existente permanece intocado!
@KafkaListener(topics = "sauron.customer-approved")
public void handleCustomerApproved(CustomerApproved event) {
    analyticsService.track(event); // ← Novo listener separado
}
Enter fullscreen mode Exit fullscreen mode

Open/Closed Principle em ação!

4. Testabilidade Aprimorada

@Test
void shouldRecordCustomerApprovedEvent() {
    // Given
    Customer customer = Customer.create("12345", "John", "john@mail.com");

    // When
    customer.approve();

    // Then
    List<DomainEvent> events = customer.pullDomainEvents();
    assertEquals(2, events.size()); // CustomerCreated + CustomerApproved
    assertTrue(events.get(1) instanceof CustomerApproved);
}
Enter fullscreen mode Exit fullscreen mode

Testar eventos é trivial. Sem mocks, sem Spring!

Padrões e Boas Práticas

1. Event Naming (Passado)

// ✅ CORRETO: Verbos no passado
CustomerCreated
OrderShipped
PaymentProcessed
InvoiceGenerated

// ❌ ERRADO: Verbos no imperativo/presente
CreateCustomer
ShipOrder
ProcessPayment
GenerateInvoice
Enter fullscreen mode Exit fullscreen mode

Eventos descrevem fatos que já aconteceram.

2. Payload Mínimo

// ✅ RECOMENDADO: Apenas referências (IDs)
public record CustomerApproved(UUID customerId) {}

// ⚠️ NÃO RECOMENDADO: Dados completos (acoplamento de schema)
public record CustomerApproved(
    UUID customerId,
    String name,
    String email,
    String document,
    Address address,
    CreditScore creditScore
) {}
Enter fullscreen mode Exit fullscreen mode

Por quê?

  • Reduz acoplamento entre producers e consumers
  • Consumers buscam dados na versão que precisam
  • Menores mensagens = melhor performance

3. Eventos São Imutáveis

// ✅ RECOMENDADO: Record (imutável)
public record CustomerCreated(UUID customerId) {}

// ⚠️ NÃO RECOMENDADO: Classe mutável
public class CustomerCreated {
    private UUID customerId; // Pode ser alterado!

    public void setCustomerId(UUID id) {
        this.customerId = id;
    }
}
Enter fullscreen mode Exit fullscreen mode

Eventos são fatos históricos. Não mudam!

4. Eventos Bem Definidos

// ✅ RECOMENDADO: Eventos específicos
CustomerApproved
CustomerRejected

// ⚠️ NÃO RECOMENDADO: Evento genérico
CustomerStatusChanged(UUID customerId, CustomerStatus newStatus)
Enter fullscreen mode Exit fullscreen mode

Eventos específicos são mais semânticos e auto documentados.

Quando Usar Domain Events

✅ Use quando:

  • Múltiplos sistemas precisam reagir a mudanças
  • Você quer histórico/auditoria completa
  • Precisa de desacoplamento entre módulos
  • Quer extensibilidade sem modificar código existente
  • Eventual consistency é aceitável

❌ Evite quando:

  • Consistency forte é obrigatória (ACID)
  • Resposta síncrona é necessária
  • Sistema é muito simples (overkill)
  • Time não tem experiência com async

Conclusão

Ao invés de pensar "o que preciso fazer agora?", pense "o que acabou de acontecer?". Seus aggregates se tornam mais focados, seu código mais testável, e seu sistema mais flexível.


Recursos

Top comments (0)