17.1 Event Contracts, Delivery Semantics, and Consumer Idempotency
An event-driven architecture connects producers and consumers through facts that have already occurred. It reduces direct temporal coupling, but turns contracts, duplicates, ordering, latency, and recovery into first-class design concerns.
Events aren't "asynchronous function calls." First, distinguish between commands and events:
- A command expresses an intention:
SettleMatch, typically has a target receiver and may be rejected; - An event expresses a fact:
MatchSettled, published by the fact owner and can have zero to multiple consumers.
Naming commands as past-tense events obscures responsibility; naming events as UpdateRanking lets the publisher decide actions for the consumer.
Events Need Sufficient, Stable Meaning
This is a project-specific envelope with complete example event and W3C tracing IDs. It is not CloudEvents format. CloudEvents 1.0 requires id, source, specversion, and type; carry business metadata according to its extension rules.
{
"eventId": "b7b6b00b-107e-4b8b-a302-6138f4390281",
"eventType": "MatchSettled",
"schemaVersion": 2,
"aggregateId": "match-8472",
"aggregateVersion": 9,
"occurredAt": "2026-08-03T10:15:30Z",
"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01",
"data": {
"winnerId": "player-42",
"loserId": "player-17"
}
}eventId must remain stable across producer retries and relay redelivery so it can support deduplication. Multiple event sources also need an explicit ID uniqueness scope. Aggregate versions can detect reordering, and gaps when the consumer receives the complete consecutive version stream. occurredAt records business event time, not broker receipt time. Limit the copying of sensitive data and large mutable objects into events.
Events can carry stable data snapshots that consumers need for common actions, avoiding an immediate synchronous query back to the producer from every consumer. The winner and loser IDs here can drive a result notification, but cannot determine rating changes. If ratings depend on pre-match scores or a rule version, include those facts or reference records that remain queryable by version.
Delivery Semantics Are an End-to-End Property
Message infrastructure often provides:
- at-most-once: Deliver an accepted message at most once, with possible loss; this does not prevent a producer from sending the same business operation as two messages;
- at-least-once: Deliver at least once under the agreed durability, retention, and continued-retry assumptions, with possible duplicates;
- Exactly-once processing capability within a constrained scope.
Even if the broker supports transactions, databases, external APIs, and consumer side effects may still fall outside the transaction scope. A more practical business goal is typically: at-least-once delivery + idempotent business outcomes + reconciliation for recovery.
Consumers should only acknowledge a message after the business result is safely committed. Acknowledging before processing risks losing messages on failure; acknowledging after processing permits redelivery if acknowledgement fails. A committed Kafka offset identifies where to resume: if offset 5 fails but 6 succeeds, committing 7 skips 5. Parallel processing must track contiguous completion, retain 5 as the recovery position, and allow 6 to be replayed idempotently.
Order is guaranteed only within the required range
Global ordering is expensive and often has no business meaning. A stable aggregateId partition key can put one match's events in the same partition while allowing different matches to proceed in parallel. Producers and relays must still publish in business order, and consumers must apply events in key order. A partition preserves append order; it cannot move an early-published version 9 behind version 8.
Consumers still have to deal with:
- Duplicate version: Ignore idempotently only when event ID and content agree; report conflicting content;
- Late old versions: decline or log;
- Version gap: Pause the key, catch up, or enter a repair workflow;
- Multi-source events: Don't assume there's a uniform order across different topics.
Schema evolution follows compatibility windows
Producers and consumers do not upgrade simultaneously. Often-compatible changes include optional fields, reader defaults, and unchanged semantics, but compatibility depends on the serialization format and validator. A strict JSON reader that rejects unknown fields can still fail on a supposedly harmless addition.
Renaming, changing types, removing fields, or reusing old fields to express new meanings are considered breaking changes and should be handled with a new version, going through:
Consumers support both new and old versions first
→ Producer releases a new version
→ Check that live consumers, retained messages, and replay jobs no longer need the old version
→ Delete compatibility codeThe Schema Registry can check structural compatibility but cannot determine if score silently changes from "current round score" to "season total score," which undermines semantics. Event contracts require owners and reviewers.
Route Errors by Category
Deserialization failures, business rejections, dependency timeouts, and code defects require different handling:
| Error | Strategy |
|---|---|
| Short-term failure | Exponential backoff retry with limits |
| Permanent schema error | Isolate and alert, don't repeatedly block |
| Repeat message | Idempotent returns success |
| Business status conflict | Record business rejection or enter repair process |
| Unknown defect | Preserve context, limit retries, enter dead letter queue |
Next lesson covers publish/subscribe, stream processing, and end-to-end event pipeline observability.
References
- Apache Kafka, Design
- CloudEvents, Specification 1.0.2
- W3C, Trace Context
- Martin Fowler, What do you mean by “Event-Driven”?