Domain Events: Transforming Changes into Opportunities
Introduction
What if your code could announce when something important happens, instead of you having to manually connect all interested systems? What if adding new behaviors didn't require modifying existing code?
Welcome to the world of Domain Events!
The Problem: Cascading Coupling
Imagine you need to implement: "When a customer is approved, send email and notify sales".
Naive Approach (coupled)
@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);
}
}
Problems:
- Direct coupling: Service knows EmailService and SalesNotificationService
- SRP violation: Approve customer + send email + notify sales = 3 responsibilities
- Hard to test: Need to mock all services
- Rigidity: Adding new behavior = modifying existing code
- Cascading failure: If email fails, approval fails too
Now imagine adding:
- Send SMS
- Update analytics
- Notify external system
- Generate event in data warehouse
- Create task in CRM
Your approveCustomer() becomes a 50-line monster with 10 dependencies!
Domain Events: The Elegant Solution
Approach with Domain Events
public class Customer extends DomainEntity {
public void approve() {
if (this.status != CustomerStatus.PENDING) {
throw new IllegalArgumentException("Customer status is not pending");
}
this.status = CustomerStatus.APPROVED;
this.updatedAt = Instant.now();
// 🎯 Just announces what happened!
this.recordDomainEvent(new CustomerApproved(this.id()));
}
}
Advantages:
- Zero coupling: Domain doesn't know who will react
- SRP preserved: Only changes state and announces
- Easy to test: No need to mock anything
- Open/Closed: Add listeners without modifying the domain
- Failure isolation: Listener fails, entity doesn't
Anatomy of a Domain Event
Base Interface
package com.github.thrsouza.sauron.domain;
public interface DomainEvent {
UUID eventId(); // Unique event identifier
String eventType(); // Event type/name
Instant eventOccurredAt(); // When it happened
}
Design decisions:
- eventId: Traceability and idempotency
- eventType: Routing (Kafka topic name)
- eventOccurredAt: Audit and temporal ordering
Implementation with Records
package com.github.thrsouza.sauron.domain.customer.events;
public record CustomerCreated(
UUID eventId,
UUID customerId,
Instant eventOccurredAt
) implements DomainEvent {
// Convenient constructor
public CustomerCreated(UUID customerId) {
this(UUID.randomUUID(), customerId, Instant.now());
}
@Override
public String eventType() {
return "sauron.customer-created";
}
}
Why Records?
- Immutable by design: Events never change
- Value semantics: Automatic equals() and hashCode()
- Serializable: Free JSON with Jackson
- Readable: Concise and clear syntax
Event Family
// Creation event
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";
}
}
// Approval event
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";
}
}
// Rejection event
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";
}
}
Consistent pattern:
- Same convenient constructor
- Same eventType format
- Minimal payloads (just IDs)
Generating Events in the Entity
DomainEntity Base Class
package com.github.thrsouza.sauron.domain;
public abstract class DomainEntity {
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;
}
}
Applied design patterns:
- Transient: Events are not persisted with the entity
- Protected: Only the entity can register events
- Pull pattern: Who persists the entity retrieves the events
- Defensive copying:
List.copyOf()returns immutable list - Clear after pull: Avoids duplicate publication
Recording Events
public class Customer extends DomainEntity {
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);
// 🎯 Records creation event
customer.recordDomainEvent(new CustomerCreated(customer.id()));
return customer;
}
public void approve() {
if (this.status != CustomerStatus.PENDING) {
throw new IllegalArgumentException("Customer status is not pending");
}
this.status = CustomerStatus.APPROVED;
this.updatedAt = Instant.now();
// 🎯 Records approval event
this.recordDomainEvent(new CustomerApproved(this.id()));
}
public void reject() {
if (this.status != CustomerStatus.PENDING) {
throw new IllegalArgumentException("Customer status is not pending");
}
this.status = CustomerStatus.REJECTED;
this.updatedAt = Instant.now();
// 🎯 Records rejection event
this.recordDomainEvent(new CustomerRejected(this.id()));
}
}
Observe:
- Events recorded after state change
- Events describe what happened (past tense)
- No business logic in events
Publishing Events
In the Use Case
public class CreateCustomerUseCase {
private final CustomerRepository customerRepository;
private final DomainEventPublisher domainEventPublisher;
public Output handle(Input input) {
// Creates a Customer instance
Customer customer = Customer.create(input.document(), input.name(), input.email());
// Persists
customerRepository.save(customer);
// 🎯 Retrieves and publishes events
domainEventPublisher.publishAll(customer.pullDomainEvents());
return new Output(customer.id());
}
}
"Pull and Publish" pattern:
- Entity generates events during operations
- Use case retrieves events from entity
- Use case delegates publication to publisher
- Publisher sends to infrastructure (Kafka, RabbitMQ, etc.)
DomainEventPublisher (Interface)
package com.github.thrsouza.sauron.domain;
public interface DomainEventPublisher {
void publishAll(Collection<DomainEvent> events);
}
Interface in the domain, implementation in infrastructure!
Kafka Implementation
@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(); // Topic name comes from event!
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());
}
});
}
}
Observability:
- Structured logs for debugging
- Partition and offset information
- Centralized error handling
Consuming Events
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; // Re-throw for retry
}
}
@KafkaListener(topics = "sauron.customer-approved", ...)
public void handleCustomerApproved(@Payload CustomerApproved event) {
log.info("📥 Received CustomerApproved - CustomerId: {}",
event.customerId());
// Here: send email, notify sales, etc.
}
@KafkaListener(topics = "sauron.customer-rejected", ...)
public void handleCustomerRejected(@Payload CustomerRejected event) {
log.info("📥 Received CustomerRejected - CustomerId: {}",
event.customerId());
// Here: rejection email, analytics, etc.
}
}
Real Benefits
1. Free Audit Trail
All events are logged and persisted in Kafka:
# List events for a customer
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"
Complete history of what happened, when and why!
2. Distributed Traceability
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
Event correlation in distributed systems!
3. Extensibility Without Modification
Before (without events):
// To add analytics, modify existing code
public void approveCustomer(UUID customerId) {
// ... existing code
emailService.send(...);
salesService.notify(...);
analyticsService.track(...); // ← New line
}
After (with events):
// Existing code remains untouched!
@KafkaListener(topics = "sauron.customer-approved")
public void handleCustomerApproved(CustomerApproved event) {
analyticsService.track(event); // ← New separate listener
}
Open/Closed Principle in action!
4. Enhanced Testability
@Test
void shouldRecordCustomerApprovedEvent() {
// Given
Customer customer = Customer.create("12345", "John", "john@mail.com");
// When
customer.approve(); // High score approves
// Then
List<DomainEvent> events = customer.pullDomainEvents();
assertEquals(2, events.size()); // CustomerCreated + CustomerApproved
assertTrue(events.get(1) instanceof CustomerApproved);
}
Testing events is trivial. No mocks, no Spring!
Patterns and Best Practices
1. Event Naming (Past Tense)
// ✅ CORRECT: Verbs in past tense
CustomerCreated
OrderShipped
PaymentProcessed
InvoiceGenerated
// ❌ WRONG: Verbs in imperative/present
CreateCustomer
ShipOrder
ProcessPayment
GenerateInvoice
Events describe facts that already happened.
2. Minimal Payload
// ✅ RECOMMENDED: Only references (IDs)
public record CustomerApproved(UUID customerId) {}
// ⚠️ NOT RECOMMENDED: Complete data (schema coupling)
public record CustomerApproved(
UUID customerId,
String name,
String email,
String document,
Address address,
CreditScore creditScore
) {}
Why?
- Reduces coupling between producers and consumers
- Consumers fetch data in the version they need
- Smaller messages = better performance
3. Events Are Immutable
// ✅ RECOMMENDED: Record (immutable)
public record CustomerCreated(UUID customerId) {}
// ⚠️ NOT RECOMMENDED: Mutable class
public class CustomerCreated {
private UUID customerId; // Can be changed!
public void setCustomerId(UUID id) {
this.customerId = id;
}
}
Events are historical facts. They don't change!
4. Well-Defined Events
// ✅ RECOMMENDED: Specific events
CustomerApproved
CustomerRejected
// ⚠️ NOT RECOMMENDED: Generic event
CustomerStatusChanged(UUID customerId, CustomerStatus newStatus)
Specific events are more semantic and self-documented.
When to Use Domain Events
✅ Use when:
- Multiple systems need to react to changes
- You want complete history/audit trail
- Need decoupling between modules
- Want extensibility without modifying existing code
- Eventual consistency is acceptable
❌ Avoid when:
- Strong consistency is mandatory (ACID)
- Synchronous response is necessary
- System is too simple (overkill)
- Team has no experience with async
Conclusion
Instead of thinking "what do I need to do now?", think "what just happened?". Your entities become more focused, your code more testable, and your system more flexible.