Designing good events
You've built a working Kafka pipeline, messages are flowing, and your consumers are processing data. But six months later, you add a new field to an event, a consumer crashes because it wasn't expecting it, and suddenly you're debugging production at 2 AM. This happens because most teams treat events as implementation details instead of contracts. Designing good events means treating them exactly like you'd design a public API—with versioning, clarity, and backward compatibility in mind.
Why events are contracts, not just messages
In a monolith, you control both sides of a function call. If you change a parameter, you recompile and deploy everything together. With event-driven systems, producers and consumers are decoupled. A payment service that publishes PaymentCompleted has no idea which teams depend on it—could be billing, reporting, fraud detection, or a new service that hasn't been written yet.
That decoupling is powerful, but it creates a contract. The producer commits to publishing certain events with certain fields. Consumers commit to handling them reliably. Break that contract carelessly—add a required field without a default, remove a field that someone depends on, or change the meaning of existing data—and you'll cause cascading failures across your platform.
Treating events as contracts means:
- Versioning them explicitly so consumers know when breaking changes happen
- Documenting what each field means and why it exists
- Never deleting fields without a deprecation period
- Making it safe for new consumers to catch up on old events
Use past tense names—they're facts, not commands
An event is a record of something that already happened. It's immutable truth. So name events in past tense: OrderPlaced, PaymentFailed, RiderArrived, InventoryAdjusted.
This distinction matters more than it sounds. When you see OrderPlaced, you immediately know: the order exists in the system now, money probably moved, inventory was probably reserved. You're reading a fact from the log, not issuing a command. This makes the event log read like a ledger—a complete audit trail of what happened and when.
Contrast this with command-style names like PlaceOrder or UpdateLocation. These feel like requests. They blur the line between "we're asking the system to do something" and "something definitely happened." In a distributed system where messages can be delayed, retried, or lost, that ambiguity is dangerous.
In a ride-hailing system, the difference is critical:
RiderLocationUpdated— the location has changed. All systems can trust this fact.UpdateRiderLocation— someone is asking the system to change it. Maybe it fails. Maybe it's in flight.
When you get RiderLocationUpdated, you can atomically update your map, calculate distance to the destination, check for surges, all at once. The event is your source of truth. With command-style names, consumers second-guess themselves: "Did this actually happen?"
Define a standard envelope for all events
Every event in your system should follow the same outer structure. This is your schema contract. It contains metadata about the event itself—not the business data, but infrastructure data that every consumer needs.
A solid event envelope includes:
- eventId: A unique identifier for this exact occurrence. Enables idempotent processing and deduplication everywhere downstream.
- type: The event category using dot notation (e.g.,
ride.location.updated,payment.succeeded). Helps route and filter. - version: Schema version of the
datafield. Lets consumers handle multiple formats gracefully. - occurredAt: When the event actually happened (not when it was published). Critical for ordering and replaying.
- source: Which service or system created the event. Helps with debugging and routing.
- data: The business payload. Versioned independently.
Here's a real example from a ride-hailing system:
{
"eventId": "evt_8f2c_a4b9_2k1n",
"type": "ride.location.updated",
"version": 2,
"occurredAt": "2026-10-22T08:30:00Z",
"source": "rider-service",
"data": {
"rideId": "r_19",
"lat": 6.9271,
"lon": 80.7789,
"accuracy": 12,
"speed": 45
}
}Every team in your organization understands this structure. When someone creates a new event, they fill in the envelope the same way. When you write a consumer, you know exactly where to find the timestamp, the ID, the type, and the versioned payload. This consistency scales as you grow from 5 services to 50.
Make eventId your deduplication anchor
Network failures happen. A producer publishes an event, Kafka acknowledges it, but the acknowledgment gets lost in transit. The producer retries and sends the same event again. Now you have duplicates in the topic. Without deduplication, that duplicate triggers payment processing twice, double-charges a customer, or books a ride twice.
The eventId is your insurance policy. It's a globally unique identifier for this specific occurrence. Your consumer stores it in a deduplication cache (Redis, or a row in your database with a unique constraint) and checks it before processing:
async function handleRideLocationUpdated(event: Event) {
// Check if we've seen this exact event before
const alreadyProcessed = await cache.get(
`processed:${event.eventId}`
);
if (alreadyProcessed) {
console.log(`Duplicate event ${event.eventId}, skipping`);
return;
}
// Process the event
await updateRideLocationInDatabase(event.data);
// Mark it as processed
await cache.set(
`processed:${event.eventId}`,
true,
{ expiresIn: 86400 } // Keep for 24 hours
);
}Format your eventId with a prefix that identifies the type and add randomness: evt_ + timestamp + random string. This makes IDs sortable, debuggable, and practically collision-free. Some teams use UUIDs, which is fine too—just make sure they're truly unique across your entire platform.
Include what consumers need, not your entire database
This is where discipline matters. When you publish an order event, the temptation is strong: "Let me just include the entire order object—customers, items, pricing, status, everything. Then consumers have all the info they need."
That's lazy event design, and it creates coupling. Suddenly, when you add a field to your Order table, every consumer of the event has to understand it, even if they don't use it. Your payment service doesn't care about the customer's loyalty tier. Your notification service doesn't need the full item descriptions. You've made the event unnecessarily large and fragile.
Instead, include the minimal context that the consumer needs to take action and fetch the rest if required:
| Event | Good (minimal context) | Bad (over-inclusive) |
|---|---|---|
OrderPlaced |
orderId, customerId, totalAmount, timestamp |
Entire order object, customer object, all items with full history |
PaymentFailed |
orderId, paymentId, reason, timestamp |
Customer email, shipping address, all previous payments on the account |
RideLocationUpdated |
rideId, lat, lon, accuracy |
Rider name, rating, vehicle details, entire ride history |
A consumer that needs the full order? They have the orderId in the event. They can fetch it from the order service's API or read-model database. Yes, that's an extra network call, but it decouples the event schema from your internal data model. Your order service can refactor its schema freely without breaking the event contract.
One exception: include denormalized data that rarely changes but saves consumers a roundtrip. In a ride event, including the rider's name and rating makes sense—they're rarely updated after ride creation, and every consumer (map, notifications, fraud detection) probably needs them. But don't include a customer's entire payment history in a payment event. That's overkill.
Version your events from day one
Six months in, you realize RiderLocationUpdated needs to include heading (compass direction) to improve map rendering. You add a heading field to the event. A legacy consumer that was built before you added heading crashes because it doesn't know how to handle the new field.
If you had versioned from the start, this is a non-issue. Version 1 of ride.location.updated` doesn't have heading. Version 2 does. New consumers request version 2. Old consumers continue processing version 1 events. You publish both versions for a transition period, then deprecate v1.
In your event envelope, the version field controls this:
async function handleLocationEvent(event: Event) {
switch (event.version) {
case 1:
// Old format: just lat/lon
await processV1(event.data);
break;
case 2:
// New format: includes heading and accuracy
await processV2(event.data);
break;
default:
console.error(`Unknown version ${event.version}`);
// Optionally forward to a dead-letter topic
}
}Here's the key: when you add a new field, it goes into a new version. Old data stays in the old version. Consumers opt into new versions as they're ready. This is how Stripe, Twilio, and other platform companies manage event schemas at scale.
Common mistakes
Mistake 1: Changing event names instead of versioning. You realize OrderCreated should be OrderPlaced, so you rename the topic and publish both names for a while. Now consumers have to listen to two topics. Instead, keep the name. If the meaning changes fundamentally, increment the version and document the difference.
Mistake 2: Forgetting deduplication in consuming code. You assume Kafka guarantees exactly-once delivery. Kafka guarantees at-least-once (with idempotent producers). The deduplication logic lives in your consumer, not the broker. Store the eventId, check before processing, every time.
Mistake 3: Including sensitive data in events. You publish a PaymentSucceeded event with the full credit card number. Now anyone with Kafka access can read card data. Sensitive fields (keys, PII, full payment methods) should never be in events. Use tokenized IDs instead.
Mistake 4: Publishing events synchronously as part of the business transaction. You process the order, save it to the database, and then publish the event. If publishing fails, the event never reaches Kafka, but the order was already persisted. Decouple this: use the Outbox pattern (write the event to your database, then a separate process polls and publishes) or use Kafka transactions.
Mistake 5: Not documenting your events. Your teams publish events but there's no central registry. New engineers don't know what events exist, what fields they have, which services publish them. Maintain a simple events catalog (even a spreadsheet or markdown file in your repo) that lists every event type, its schema, version history, and consuming services.
Key takeaways
- Treat events as contracts between producers and consumers, not implementation details. Design them with versioning and backward compatibility in mind.
- Use past tense names (
OrderPlaced,PaymentFailed) to reflect facts that have happened, not commands or requests. - Every event should follow a standard envelope containing
eventId,type,version,occurredAt,source, anddata. This consistency scales across your entire platform. - Make
eventIdunique and globally tracked. Use it for deduplication at every consumer, protecting against duplicate processing from retries. - Include only the context consumers need to act and look up the rest if required. Avoid dumping entire database rows into events—it creates coupling and fragility.
- Version your event schemas from day one. When business requirements change, increment the version and support both old and new formats during a transition period.
- Document your events in a central catalog so teams understand what's available, what each field means, and which services depend on it.






Comments
Loading comments…