Domain Events: Transforming Changes into Opportunities

8 min read
By Thiago Souza
Domain EventsDDDEvent-Driven ArchitectureSoftware Design

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:

  1. Direct coupling: Service knows EmailService and SalesNotificationService
  2. SRP violation: Approve customer + send email + notify sales = 3 responsibilities
  3. Hard to test: Need to mock all services
  4. Rigidity: Adding new behavior = modifying existing code
  5. 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:

  1. Transient: Events are not persisted with the entity
  2. Protected: Only the entity can register events
  3. Pull pattern: Who persists the entity retrieves the events
  4. Defensive copying: List.copyOf() returns immutable list
  5. 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:

  1. Entity generates events during operations
  2. Use case retrieves events from entity
  3. Use case delegates publication to publisher
  4. 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.


Resources