Domain events let you capture meaningful state changes while keeping aggregates loosely coupled. This article walks through the anatomy of an event, publishing lifecycles, in‑process vs out‑of‑process handling, idempotency, the transactional outbox pattern, versioning, and testing strategies, highlighting the scalability and consistency implications of each choice.
Domain Events: Design, Implementation, and Trade‑offs

The problem – tightly coupled aggregates
In many legacy codebases, a change in one aggregate (e.g., an Order) directly invokes methods on another aggregate (Inventory). The call chain often spans multiple layers, mixes business logic with infrastructure concerns, and makes it hard to reason about consistency. When the system grows, adding a new consumer (say, a shipping service) forces you to sprinkle more direct calls, increasing the risk of circular dependencies and hidden side effects.
A tactical DDD pattern – domain events
A domain event is a record of something that has already happened in the domain, expressed in the ubiquitous language of the business. Typical examples are OrderSubmitted, PaymentReceived, or InventoryDepleted. The event carries:
- Aggregate identifier – the primary key of the source aggregate.
- Timestamp – when the event occurred.
- Event type – a past‑tense name that distinguishes the fact from a command.
- Payload – only the data that matters to domain experts, not internal fields.
Why past tense? It signals that the system has already performed the state transition. Commands (
SubmitOrder) are intents; events (OrderSubmitted) are facts.
Publishing lifecycle – keeping state and events atomic
- Raise – Inside the aggregate, a command handler creates the event and appends it to an internal list of unpublished events.
- Persist – The repository saves the aggregate and writes the pending events to the same database transaction.
- Publish – After the transaction commits, a dispatcher reads the unpublished events and forwards them to the appropriate handlers.
The atomic write of both aggregate state and event rows eliminates the classic dual‑write problem: without it, a crash after persisting the aggregate but before publishing would leave downstream services out of sync.
Handling strategies – in‑process vs out‑of‑process
| Aspect | In‑process handlers | Out‑of‑process handlers |
|---|---|---|
| Scope | Same bounded context, same process | Different services or micro‑services |
| Delivery guarantee | Synchronous, part of the same transaction | Asynchronous, at‑least‑once delivery |
| Typical side effects | Cache invalidation, domain‑internal notifications | Email, external APIs, long‑running workflows |
| Performance impact | Must be fast; otherwise the transaction stalls | Can be slower; decoupled from the core transaction |
When to choose each
- Use in‑process for effects that must be strongly consistent with the state change (e.g., updating a read model that other parts of the same service query immediately).
- Use out‑of‑process when the consumer lives in another bounded context or when the work can tolerate eventual consistency.
Idempotency – a non‑negotiable requirement
Message brokers may redeliver the same event multiple times. Handlers therefore need a deterministic way to detect duplicates. Common techniques:
- Deduplication table – Store the event ID with a unique constraint; ignore inserts that violate it.
- Idempotent operations – Design the handler to be safe to run repeatedly (e.g.,
SET balance = balance + 0instead ofbalance = balance + amount). - State‑check before act – Verify the target state already reflects the event before applying changes.
The transactional outbox pattern – solving dual writes
- Within the aggregate’s transaction, insert a row into an
outboxtable for each raised event. - Commit the transaction – both the aggregate and outbox rows are now durable.
- A separate outbox processor reads unpublished rows, publishes them to a broker (Kafka, RabbitMQ, etc.), and marks them as dispatched.
This pattern gives you atomicity without distributed transactions and guarantees at‑least‑once delivery. The processor can be scaled horizontally; each instance picks up distinct rows using a lock or a WHERE processed = false filter.
Event versioning – handling schema evolution
Because events are contracts between producers and consumers, changing their shape must be managed carefully.
- Additive changes – New optional fields are safe; older consumers ignore them.
- Removing fields – Deprecate first, then create a new version (e.g.,
OrderSubmittedV2). - Schema registry – Tools like Confluent Schema Registry enforce compatibility rules automatically.
- Keep old classes – Retain previous event definitions in the codebase so that deserialization of historic records still works.
Forward compatibility (new consumers reading old events) and backward compatibility (old consumers reading new events) require disciplined design: default values, explicit version fields, and clear documentation.
Testing domain events – from unit to integration
- Unit tests – Verify that a command on an aggregate produces the expected event objects with correct payloads. Mock the event list and assert its contents.
- In‑process handler tests – Invoke the handler directly, assert side effects (e.g., cache entry removed) and confirm it runs quickly.
- Integration tests – Spin up a real database and a lightweight broker (or an in‑memory outbox processor). Execute the full command‑save‑publish flow and assert that the downstream consumer receives the event and produces the intended outcome.
- Idempotency tests – Deliver the same event twice and ensure the system state does not diverge.
Automating these layers gives confidence that refactoring an aggregate or adding a new consumer will not break existing contracts.
Trade‑offs at a glance
- Scalability – Out‑of‑process handling with a message broker lets you add consumers without touching the core service, but you accept eventual consistency.
- Consistency – In‑process handlers give strong consistency but tie the consumer’s latency to the transaction.
- Complexity – The outbox pattern adds a background process and a table, but it removes the need for distributed two‑phase commits.
- Operational overhead – Managing schema versions and idempotency logic requires discipline and tooling.
Takeaways
- Model events as facts in the ubiquitous language; keep payloads minimal and domain‑focused.
- Persist events atomically with the aggregate using the transactional outbox.
- Choose the handling strategy that matches the required consistency level.
- Build idempotent handlers and version your events deliberately.
- Test at every layer to catch regressions early.
Further reading
- Transactional Outbox Pattern – Microsoft Docs
- Domain Events in DDD – Martin Fowler
- Event Versioning Strategies – Confluent Blog

The concepts above are distilled from a longer tutorial originally published on AI Study Room. The full version includes runnable code samples in C# and Java, a comparison matrix of broker choices, and a step‑by‑step guide to wiring the outbox processor.

Comments
Please log in or register to join the discussion