Event design¶
How to design the events that applications publish, so that they are useful as data: what an event represents, how it is named, which fields every event carries, and how it changes over time.
Before reading
Why it matters¶
Events are the main way applications share what happens with each other and with the data platform. Badly designed events are hard to use for years: a vague name, a missing timestamp, no ID to deduplicate on, or a payload that only makes sense with the sender's internal code. Designing events well costs little at the start and saves every consumer work later.
How it works¶
An event records a fact: something that happened, in the past tense. OrderPlaced, PaymentCaptured, ParcelDelivered. It is different from a command, such as PlaceOrder, which asks for something to happen and may be refused. Facts cannot be refused or changed; a correction is a new event, such as OrderAmountCorrected. For data, facts are what matter, because they can be counted and replayed.
Names follow a pattern: the business object and what happened to it, in the past tense, in the language of the business rather than of a database table. OrderPlaced is better than OrdersTableInsert or OrderEvent with a type field that hides what happened. In an envelope the name is often written as a dotted type with a namespace and version, such as shop.order.placed.v1 for OrderPlaced.
Every event carries a standard envelope, separate from its business payload:
- a unique event ID, set by the producer, used to deduplicate;
- the event type and its schema version (in CloudEvents, part of type or given by the dataschema URI);
- the source, such as the service that produced it;
- the subject, the ID of the business object it is about, which is usually also the Kafka key;
- the time the event happened, and often the time it was recorded;
- a correlation or trace ID linking events of one business process.
CloudEvents, a specification from the Cloud Native Computing Foundation, standardises these envelope fields (id, source, type, subject, time and others) and how they are carried in Kafka headers, HTTP and other transports. Using it, or an envelope modelled on it, lets platforms handle events from many producers in the same way.
flowchart LR
subgraph E[Event: OrderPlaced]
direction TB
ENV[Envelope:<br/>id, type with version,<br/>source, subject, time] ~~~ PAY[Payload:<br/>order_id, customer_id,<br/>lines, amount, currency]
end
SVC[Orders service] --> E
E -->|key = subject| K[Topic:<br/>shop.order-events]
Granularity is a design choice. Fine-grained events, such as OrderAddressChanged and OrderLineAdded, say exactly what happened but force consumers to rebuild state. Coarse events, such as OrderUpdated with the full order, are easy to consume but hide what changed. A common compromise is a specific event type that carries the full current state of the object after the change, so consumers get both the reason and the state.
Payloads should be self-contained enough to use: IDs plus the values consumers commonly need, in clear units and types. Amounts carry their currency; times are ISO 8601 timestamps in UTC with the offset written, as in 2026-10-28T09:14:00Z; codes are documented. Personal data is included only where consumers need it, since every event is copied into topics, lakes and logs.
Events change over time like any schema. Changes follow the compatibility rules of the schema registry, and the event's version is part of its envelope. Breaking changes create a new event version, published alongside the old one for a transition period, as for any data contract.
Key terms¶
- Event: a record of a fact that happened.
- Command: a request for something to happen, which may be refused.
- Envelope: standard metadata fields carried by every event.
- Subject: the business object an event is about.
- Correlation ID: an ID that links events belonging to one process.
- CloudEvents: a CNCF specification for common event metadata.
- Granularity: how much change one event describes.
Example¶
Two of the shop's order events, in CloudEvents form with a JSON payload:
{
"specversion": "1.0",
"id": "evt-5f1c",
"source": "shop/orders-service",
"type": "shop.order.placed.v1",
"subject": "o-1002",
"time": "2026-10-28T09:14:00Z",
"datacontenttype": "application/json",
"data": { "order_id": "o-1002", "customer_id": "c-88",
"amount": 129.00, "currency": "EUR", "line_count": 2 }
}
{
"specversion": "1.0",
"id": "evt-6a02",
"source": "shop/orders-service",
"type": "shop.order.cancelled.v1",
"subject": "o-1002",
"time": "2026-10-28T11:02:00Z",
"data": { "order_id": "o-1002", "reason": "customer_request" }
}
Both are keyed by o-1002, so they stay in order on the topic; the platform deduplicates on id and orders by time and offset.
Pitfalls¶
- Commands disguised as events. Consumers cannot tell whether the thing happened.
- Generic events with a type field. OrderEvent with action = 3 forces every consumer to know the producer's code.
- No producer-set ID. Retries cannot be recognised as duplicates.
- Local times or missing units. Every consumer guesses differently.
- Internal table rows as events. See The outbox pattern.
Learn more¶
- CloudEvents specification: https://github.com/cloudevents/spec/blob/main/cloudevents/spec.md
- Martin Fowler, What do you mean by "Event-Driven"?: https://martinfowler.com/articles/201701-event-driven.html
Related¶
The outbox pattern · Data contracts · Delivery guarantees and idempotency · Late and out-of-order data