Software Architecture · Domain Modeling

Domain Events: Publishing State Transitions Without Losing Them

Why the database commit and the event publish are two writes that can disagree, how an outbox closes the gap, and what ordering and duplication guarantees consumers actually get.

An order moves from placed to shipped. The database records the new status, and three other parts of the system need to know: the notification service that emails the customer, the warehouse that releases the packing slip, and the reporting table that tracks fulfilment. Each of those needs a message. The interesting question is not what the message contains but when it is sent relative to the commit, and what happens when the two disagree.

A transition is a fact with a timestamp

A domain event names something that has already happened, in the past tense, with the identifiers needed to find the subject and the time it occurred. OrderShipped, not ShipOrder. The naming matters because it fixes the semantics: an event cannot be rejected, only recorded, and a consumer that receives it is looking at history rather than at a request.

The minimum content is the aggregate identifier, the event type, the time of occurrence, and the aggregate version at which it occurred. The version is the piece most often omitted and the one that makes ordering explicit: two events from the same aggregate with versions 7 and 8 have a definite order, while two events with the same version indicate a bug or a duplicate. A globally unique event identifier is also worth carrying, because it is what consumers use to deduplicate.

Events are values, in the sense of Value Objects and Entities: immutable, compared by content, and never edited after publication. An event that is corrected in place destroys the audit trail it was meant to provide; a correction is a new event that records the change.

The dual-write problem and the outbox

The straightforward implementation writes the state change and then publishes to a broker. That is two writes to two systems with no shared transaction. If the process crashes between them, the state change exists and the event does not. If the publish succeeds and the commit fails, the event exists for a change that never happened, and consumers act on a ghost.

Retrying the publish does not fix it, because the failure can occur after the broker accepted the message but before the acknowledgement reached the application; the retry produces a duplicate. Wrapping both in a distributed transaction is possible in principle and unattractive in practice: it requires two-phase commit across a database and a message broker, holds locks across network round trips, and is unsupported by many broker and database combinations.

The outbox removes the second write from the critical path. The event is inserted into a table in the same database, in the same local transaction as the state change. A separate process reads unpublished rows and sends them, marking them as sent. Because the insert and the state change commit together, either both exist or neither does.

CREATE TABLE outbox (
  id           BIGSERIAL PRIMARY KEY,
  aggregate_id TEXT        NOT NULL,
  aggregate_version BIGINT NOT NULL,
  event_type   TEXT        NOT NULL,
  payload      JSONB       NOT NULL,
  occurred_at  TIMESTAMPTZ NOT NULL,
  published_at TIMESTAMPTZ
);

-- Written in the same transaction as the state change.
INSERT INTO outbox (aggregate_id, aggregate_version, event_type, payload, occurred_at)
VALUES ($1, $2, 'OrderShipped', $3, now());

The relay has to be designed for at-least-once semantics, because marking a row as published after the send can fail in the same window as before. Selecting rows with FOR UPDATE SKIP LOCKED lets several relay instances share the table without blocking each other, and marking each row individually means a crash loses at most the in-flight batch, which is re-sent.

The outbox has costs. It adds a table that grows quickly and must be pruned, an extra write per transaction, and a polling or change-capture mechanism whose latency becomes the floor on event delivery. It also couples the event store to the operational database, which is usually the right coupling but is not free: a database failover now delays event delivery, and a schema migration on the outbox table is a migration on the hot path.

Thin events, fat events, and what consumers can trust

A thin event carries identifiers and little else; the consumer calls back into the producing context to fetch current state. A fat event carries enough data for the consumer to act without a callback.

The thin version has one strong property: the consumer sees the state as it is now, not as it was, so there is no chance of acting on stale attributes. Its cost is a synchronous dependency on the producer’s query API, which reintroduces the runtime coupling that asynchronous messaging was supposed to remove. It also has a correctness trap: if the consumer handles events out of order, a callback for an older event returns newer state, and the consumer may apply the newer state twice.

The fat version decouples at runtime and freezes the data at the moment of the transition, which is what an audit trail needs. Its cost is that the payload is a contract: consumers depend on fields, and removing a field breaks them. Adding a field is usually safe for JSON consumers that ignore unknown properties; renaming is not. Event schemas therefore need the same versioning discipline as API schemas, and the payload should carry only fields the event is genuinely about, not a serialized aggregate.

A useful compromise is a fat event with the identifying attributes and a link back for anything else. The consumer can handle the common case without a call and can fetch details when it needs them.

Ordering and duplication are separate problems

Ordering guarantees depend on the transport and on the partition key. A single queue with one consumer delivers in order; a partitioned log guarantees order within a partition, which means the partition key must be the aggregate identifier, or two events for the same aggregate land in different partitions and can be consumed in either order. Keying by aggregate id is the standard choice, and it has a consequence: a hot aggregate creates a hot partition, and throughput for that key is bounded by one consumer.

Duplication is unavoidable at this layer. Every at-least-once transport will redeliver after a timeout, and an outbox relay will re-send after a crash. Consumers therefore need idempotent handling: record the event identifier in a table with a unique constraint and skip the work if the insert conflicts, or make the operation naturally idempotent by keying the write on something stable. The first is general and cheap; the second requires domain knowledge and is easy to get wrong when the operation has side effects such as sending an email.

Note that idempotency keyed on the event identifier handles duplicates but not reordering. A consumer that receives version 8 before version 7 must either buffer or reject the older one. Buffering requires knowing when the gap will be filled, which is not knowable without a timeout; rejecting requires the producer to re-send, which most brokers do not do on demand. The practical choice is to make each event carry enough state that it can be applied independently, which turns the ordering question into an assignment of the latest version per aggregate.

Events as a historical record

Once events are persisted, they can serve as more than notifications. Replaying them rebuilds state, which is the basis of event sourcing: the current state is a fold over the event stream, and the stream is the system of record. That choice replaces the update-in-place model with an append-only log, and it moves several problems rather than solving them. Reads become expensive without projections, schema evolution affects historical events, and deleting data for privacy requires crypto-shredding or rewriting history.

A middle path keeps a normal relational model as the system of record and uses events only for integration and audit. It is much easier to operate, and it is sufficient for the majority of systems that want the notification semantics without the storage model. The distinguishing question is whether the history itself is a business asset — whether someone will ask what the state was on a given date, or why it changed — and that question leads into Modeling Validity Over Time.

Independently of which path is taken, events make the aggregate boundary observable. A well-shaped event names one transition of one aggregate, carries the version that orders it, and can be consumed by a system that knows nothing about the producer’s internals. An event that carries a whole object graph usually means the boundary is in the wrong place, which is the concern of Aggregate Boundaries and Transaction Scope.

References

  1. Domain-Driven Design: Tackling Complexity in the Heart of Software — Eric Evans, Addison-Wesley, 2003
  2. Event Sourcing — Martin Fowler
  3. Enterprise Integration Patterns — Gregor Hohpe and Bobby Woolf, Addison-Wesley, 2003