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);
}
}
Problemas:
- Acoplamento direto: Service conhece EmailService e SalesNotificationService
- Violação SRP: Aprovar cliente + enviar email + notificar vendas = 3 responsabilidades
- Difícil testar: Precisa mockar todos os serviços
- Rigidez: Adicionar novo comportamento = modificar código existente
- 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()));
}
}
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
}
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";
}
}
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";
}
}
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;
}
}
Design patterns aplicados:
- Transient: Eventos não são persistidos com o aggregate
- Protected: Só o aggregate pode registrar eventos
- Pull pattern: Quem persiste o aggregate puxa os eventos
-
Defensive copying:
List.copyOf()retorna lista imutável - 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()));
}
}
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());
}
}
Padrão "Pull and Publish":
- Aggregate gera eventos durante operações
- Use case recupera eventos do aggregate
- Use case delega publicação ao publisher
- Publisher envia para infraestrutura (Kafka, RabbitMQ, etc.)
DomainEventPublisher (Interface)
package com.github.thrsouza.sauron.domain;
public interface DomainEventPublisher {
void publishAll(Collection<DomainEvent> events);
}
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());
}
});
}
}
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.
}
}
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"
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
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
}
Depois (com eventos):
// Código existente permanece intocado!
@KafkaListener(topics = "sauron.customer-approved")
public void handleCustomerApproved(CustomerApproved event) {
analyticsService.track(event); // ← Novo listener separado
}
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);
}
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
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
) {}
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;
}
}
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)
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.
Top comments (0)