Software Architecture · Architectural Styles

Event Notification and Event-Carried State Transfer Compared

Two events can carry the same name and different obligations: one says a fact changed and leaves the state behind, the other carries the state. The choice sets coupling, ordering needs, and deduplication cost.

An order service publishes OrderPlaced. A shipping service reacts by creating a shipment. Whether that is cheap or expensive depends entirely on what the event body contains, and the two common answers give styles with different coupling, different ordering requirements, and different failure behaviour under duplicate delivery.

Event notification carries a reference. The event names what happened and identifies the subject, and the consumer must ask the source for anything else it needs. Event-carried state transfer carries the state. The event body includes the fields consumers require, and the consumer can build its own view without calling back. Martin Fowler’s taxonomy of event-driven integration separates these two from a third, event sourcing, which treats the event log as the system of record rather than as a message about a record that lives elsewhere.

The distinction looks like a serialization decision and is not. It determines who owns the definition of the data consumers see, what has to be ordered, and what “processing the same event twice” costs.

What the consumer must do after receiving each

With notification, the consumer’s handling has two steps: interpret the event, then read the current state from the producer’s interface. If an order is placed, the shipping service loads the order to learn its lines, destination, and weight. The producer’s read interface becomes part of the integration contract, and the event alone is not enough to act.

That read-back has consequences that are easy to miss. It adds a network round trip to every handled event, so the consumer’s throughput is bounded by the producer’s read capacity as well as its own. It creates a temporal dependency during handling: if the producer is unavailable, the consumer cannot process, and its queue grows. And it introduces a read-your-writes question, because the read may hit a replica that has not yet observed the write that produced the event. The consumer that reads immediately after notification can legitimately see the pre-change state. The usual mitigation is retry with backoff, which converts a rare race into a routine delay.

With state transfer, handling is one step: the consumer folds the payload into its own store. No call back to the producer is needed at handling time. The producer’s read interface can be private, the consumer can process while the producer is down, and the read-your-writes race disappears because the data arrived in the message.

The price is on the producer side. Every field a consumer needs must be in the event, and the producer must know what those fields are. That knowledge is a coupling: adding a consumer that needs the shipping address means changing the producer, and the producer’s event schema now serves downstream requirements rather than upstream facts.

The two bodies for the same order differ in what a consumer can do without calling back. The notification form:

{ "type": "OrderPlaced", "orderId": "b41f", "occurredAt": "2025-03-02T10:14:00Z" }

The state-transfer form:

{
  "type": "OrderPlaced",
  "orderId": "b41f",
  "occurredAt": "2025-03-02T10:14:00Z",
  "customerId": "c7d2",
  "lines": [{ "sku": "A-118", "quantity": 2 }],
  "destination": { "country": "NL", "postalCode": "1012" }
}

The second body is several times larger for a small order, and unbounded for a large one: a thousand-line order produces an event proportional to the order. Kafka’s default maximum message size is about 1 MiB, so bulk payloads must be summarized, chunked, or moved to a claim-check reference — which reintroduces the callback for the large case. A design that assumes state transfer scales to arbitrary aggregate size will discover the cap at the worst moment.

Ordering requirements follow from the payload

Notification tolerates reordering better than it first appears. If two events for the same order arrive out of order, each one triggers a read of current state, and both reads return the same current state. The final view is correct regardless of processing order, because the consumer never trusted the event body for values. What breaks under reordering is anything that depends on the event itself, such as counting placements or deriving a transition sequence.

State transfer is order-sensitive in the strict sense. If OrderUpdated with a new destination is processed before the earlier OrderUpdated with the old one, the consumer’s copy ends up wrong and stays wrong, because nothing forces a re-read. Ordering must therefore be preserved per subject — partition by order identifier, and let the partition ordering do the work — or each event must carry a version that the consumer compares against what it holds. A version is a monotonic counter per aggregate, or the aggregate’s current version, and the consumer applies the event only if the incoming version exceeds the stored one. Per-key ordering is the cheaper mechanism when the transport supports it; version comparison is the mechanism that survives a transport without ordering guarantees.

A related trap is mixing the two. An event that carries a version and a partial payload requires the consumer to merge, and a consumer that missed an intermediate event has no base to merge onto. Either the payload is complete for its version, or the consumer falls back to a read. Hybrid bodies that are complete only sometimes are the hardest to reason about.

Duplicates are cheap for one and expensive for the other

Transports that guarantee at-least-once delivery will redeliver, and both styles must decide what a second processing means.

For notification, the handler is usually idempotent by construction: reading state and writing a derived result can be repeated. The read costs another round trip, and a handler that appends on each delivery still accumulates duplicates — so the identity of the event or the subject must gate the append. The deduplication key is the event identifier or, more robustly, a domain fact such as “this shipment for this order line already exists”.

For state transfer, a duplicate is harmless if the payload is applied by assignment and the version check rejects an equal version. It is harmful if the payload is applied incrementally — a counter, a balance, an append-only ledger entry derived from a delta. The rule that follows: state-transfer events should describe the new state or a version, not a delta, unless the transport gives exactly-once per key. Deltas are the payload equivalent of trusting ordering.

Where the choice is made

The decision is per event type, not per system, and three considerations settle most cases.

The first is how many consumers need the same fields. One consumer can call back cheaply and the producer does not need to anticipate fields. Several consumers with different needs argue for state transfer, because the alternative is several read-back paths against the same interface and the producer’s availability as a shared dependency. The count at stake is small, but each additional read-back caller multiplies the load on the producer’s read path by the event volume.

The second is whether the producer’s read interface exists for other reasons. If there is already a public query API, notification adds no new surface. If the read interface would be created solely for consumers, state transfer avoids adding one.

The third is whether the consumer can be rebuilt from the events alone. That property belongs to event sourcing, and the append-only log as system of record covers what it costs. State-transfer events over a broker are not a log; retention expires them and replay is not guaranteed, so a consumer that stores only what it received can never be reconstructed after a bug.

Failure modes at the seam

The producer’s model becoming the consumer’s model is the failure state transfer invites. Once consumers accept a payload shaped like the producer’s internal entity, every renamed field is a breaking change and every internal refactor becomes a public one. A separate published event schema, with its own versioning and its own review, is the counterweight. The schema should name facts in the language of the domain, not the producer’s storage layout.

Silent truncation is the second. A producer that serializes an aggregate into a fixed envelope and drops unknown fields produces events that look complete and are not, and consumers that trust them build views with holes. Schema validation at the published boundary catches this before consumers do.

Another failure is the consumer that treats notification as if it were state transfer: reading the event identifier and assuming the rest of the record is unchanged since the last event it saw. That assumption holds only while every mutation publishes a notification and none are dropped, and it converts a missed event into permanent divergence. When a consumer needs full state, the durable answer is a scheduled reconciliation pass that re-reads and repairs, not an assumption. Projects that must serve queries from their own copy of the data usually need a separate mechanism for that copy, which is the subject of CQRS and projection lag.

References

  1. What do you mean by Event-Driven? — Martin Fowler
  2. Implementing Domain-Driven Design — Vaughn Vernon — Addison-Wesley
  3. Enterprise Integration Patterns — Hohpe and Woolf