Domain Event Pattern
Capture and publish major occurrences within a domain model to decouple side effects from core business logic and enable reactive workflows.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
The Domain Event Pattern captures major business occurrences within a domain model as first-class objects. When something meaningful happens — an order is placed, a user is registered, a payment fails — the domain emits an event. Other parts of the system react to these events rather than being called directly.
This decouples core business logic from side effects like sending emails, updating analytics, or notifying downstream services. Domain events also enable event sourcing, where the state of an aggregate is reconstructed by replaying its event history.
When to Use
- For alternatives, see Event Bus Pattern.
Use the Domain Event Pattern when:
- Side effects should not be triggered directly by business logic
- Multiple subsystems need to react to the same business occurrence
- You need an audit trail of what happened in the system and when
- Eventual consistency between bounded contexts is acceptable
- You want to enable event sourcing for aggregates
When to Avoid
- Simple CRUD applications where direct method calls are sufficient
- Synchronous operations where immediate feedback is required
- Debugging event chains becomes difficult in large systems
- Over-abstraction: not every setter needs to emit an event
Solution
Python
from dataclasses import dataclass, field
from datetime import datetime
from typing import List, Callable
from uuid import UUID, uuid4
@dataclass(frozen=True)
class DomainEvent:
event_id: UUID = field(default_factory=uuid4)
occurred_at: datetime = field(default_factory=datetime.now)
aggregate_id: str = ""
event_type: str = ""
payload: dict = field(default_factory=dict)
class EventPublisher:
_handlers: List[Callable] = []
@classmethod
def subscribe(cls, handler: Callable):
cls._handlers.append(handler)
@classmethod
def publish(cls, event: DomainEvent):
for handler in cls._handlers:
handler(event)
class Order:
def __init__(self, customer_id: str):
self.id = str(uuid4())
self.customer_id = customer_id
self.lines = []
self.status = "pending"
self._events: List[DomainEvent] = []
def add_line(self, product_id: str, quantity: int, price: float):
self.lines.append({"product_id": product_id, "quantity": quantity, "price": price})
def submit(self):
if not self.lines:
raise ValueError("Cannot submit empty order")
self.status = "submitted"
self._events.append(DomainEvent(
aggregate_id=self.id,
event_type="OrderSubmitted",
payload={"customer_id": self.customer_id, "line_count": len(self.lines)}
))
def clear_events(self):
events = list(self._events)
self._events.clear()
return events
# Usage
order = Order("cust-123")
order.add_line("prod-1", 2, 9.99)
order.submit()
for event in order.clear_events():
EventPublisher.publish(event)
Java
import java.time.Instant;
import java.util.*;
public record DomainEvent(
UUID eventId,
Instant occurredAt,
String aggregateId,
String eventType,
Map<String, Object> payload
) {
public DomainEvent(String aggregateId, String eventType, Map<String, Object> payload) {
this(UUID.randomUUID(), Instant.now(), aggregateId, eventType, payload);
}
}
class EventPublisher {
private static final List<java.util.function.Consumer<DomainEvent>> handlers = new ArrayList<>();
public static void subscribe(java.util.function.Consumer<DomainEvent> handler) {
handlers.add(handler);
}
public static void publish(DomainEvent event) {
handlers.forEach(h -> h.accept(event));
}
}
class Order {
private final UUID id = UUID.randomUUID();
private final String customerId;
private final List<Map<String, Object>> lines = new ArrayList<>();
private String status = "pending";
private final List<DomainEvent> events = new ArrayList<>();
public Order(String customerId) {
this.customerId = customerId;
}
public void addLine(String productId, int quantity, double price) {
lines.add(Map.of("product_id", productId, "quantity", quantity, "price", price));
}
public void submit() {
if (lines.isEmpty()) throw new IllegalStateException("Cannot submit empty order");
status = "submitted";
events.add(new DomainEvent(
id.toString(), "OrderSubmitted",
Map.of("customer_id", customerId, "line_count", lines.size())
));
}
public List<DomainEvent> clearEvents() {
List<DomainEvent> result = new ArrayList<>(events);
events.clear();
return result;
}
}
// Usage
EventPublisher.subscribe(event -> System.out.println("Received: " + event.eventType()));
Order order = new Order("cust-123");
order.addLine("prod-1", 2, 9.99);
order.submit();
order.clearEvents().forEach(EventPublisher::publish);
JavaScript
class DomainEvent {
constructor(aggregateId, eventType, payload = {}) {
this.eventId = crypto.randomUUID();
this.occurredAt = new Date().toISOString();
this.aggregateId = aggregateId;
this.eventType = eventType;
this.payload = payload;
}
}
class EventPublisher {
static handlers = [];
static subscribe(handler) {
this.handlers.push(handler);
}
static publish(event) {
this.handlers.forEach(h => h(event));
}
}
class Order {
constructor(customerId) {
this.id = crypto.randomUUID();
this.customerId = customerId;
this.lines = [];
this.status = 'pending';
this._events = [];
}
addLine(productId, quantity, price) {
this.lines.push({ productId, quantity, price });
}
submit() {
if (this.lines.length === 0) throw new Error('Cannot submit empty order');
this.status = 'submitted';
this._events.push(new DomainEvent(
this.id, 'OrderSubmitted',
{ customer_id: this.customerId, line_count: this.lines.length }
));
}
clearEvents() {
const events = [...this._events];
this._events = [];
return events;
}
}
// Usage
EventPublisher.subscribe(event => console.log('Received:', event.eventType));
const order = new Order('cust-123');
order.addLine('prod-1', 2, 9.99);
order.submit();
order.clearEvents().forEach(e => EventPublisher.publish(e));
Explanation
A Domain Event is:
- Immutable: Once created, it never changes. It represents something that already happened.
- Named in past tense:
OrderSubmitted,PaymentFailed,UserRegistered. - Rich in context: Includes the aggregate ID, timestamp, and relevant payload data.
- Published after state changes: The aggregate changes state first, then emits events describing what changed.
Variants
| Variant | Delivery | Use Case |
|---|---|---|
| In-memory | Synchronous within process | Simple decoupling inside a monolith |
| Outbox | Async via database table | Reliable cross-service delivery |
| Event Sourcing | Events are the source of truth | Full audit trail and temporal queries |
| CQRS | Read model projections | Separate read and write models |
What Works
- Name events in past tense.
OrderPlaced, notPlaceOrder. Events describe things that already happened. - Keep events small. Include only the data needed for consumers. Do not include full aggregate state.
- Use UUIDs for event IDs. This enables idempotency and traceability across services.
- Include timestamps.
occurred_athelps with ordering, debugging, and analytics. - Clear events after publishing. Aggregates should not accumulate unbounded event lists in memory.
Common Mistakes
- Emitting events before state changes. If the state change fails, the event has already been published, causing inconsistency.
- Forgetting to clear events causes memory leaks and duplicate publishing on subsequent operations.
- Putting too much data in payloads bloats the event bus and couples consumers to internal structures.
- Treating commands as events.
PlaceOrderis a command;OrderPlacedis an event. Do not confuse them. - Missing event versioning. As the payload schema evolves, old consumers break. Version your events.
Real-World Examples
Axon Framework
Java framework built around domain events and event sourcing. Aggregates emit events; event handlers build read models.
EventStoreDB
A database designed for event sourcing. Streams of domain events are persisted as the primary data model.
Stripe Webhooks
Stripe publishes charge.succeeded, invoice.paid, and other domain events to webhooks. Your application reacts to them asynchronously.
Troubleshooting
- Pattern does not fit the problem: re-evaluate the forces (performance, scalability, team size, coupling). A pattern is only appropriate when its trade-offs match your constraints.
- Too many abstractions: if adding a pattern increases complexity without a clear benefit, simplify. Not every module needs a factory, decorator, or strategy.
- Tight coupling after refactoring: check that interfaces are stable and dependencies point inward. Use dependency inversion to break accidental coupling.
- Tests break when the design changes: favor stable contracts over internal structure. Test observable behavior, not private helpers.
- Performance regression from indirection: measure before and after. Layers, decorators, and adapters can add latency; cache or inline hot paths if needed.
Common Production Pitfalls
- Applying the pattern where no abstraction is needed, adding accidental complexity.
- Letting the pattern leak into unrelated modules and blur ownership boundaries.
- Over-engineering the first implementation instead of starting simple and measuring pain.
- Skipping contract tests, so refactors silently break consumers.
- Ignoring failure modes that the pattern does not cover.
- Using the pattern as a default instead of choosing the right tool for the current scale.
- Forgetting to document when to stop using the pattern and what replaces it.
- Missing observability around the pattern’s performance and error propagation.
Related Resources
Aggregate Pattern
Encapsulate a cluster of domain objects treated as a single unit for data changes. An Aggregate Root controls access to its internal entities and value objects.
PatternOutbox Pattern
Reliably publish domain events by persisting them in an outbox table within the same database transaction as the business operation.
PatternEvent Bus Pattern
Decouple components by routing events through a central bus. A behavioral pattern for loosely coupled communication between modules.
Frequently Asked Questions
- What is the difference between a Domain Event and an Integration Event?
- Domain events stay within a bounded context. Integration events cross service boundaries and are usually published via a message broker.
- Should I store domain events in a database?
- Yes, if you use event sourcing or the outbox pattern. For simple in-memory decoupling, storage is optional.
- Can I modify a domain event after creating it?
- No. Events represent immutable facts. If you need to correct something, publish a compensating event like OrderCancelled.