In event-driven systems, publishing an event is not only about sending a message.
The shape of the event matters.
A good event payload makes the system easier to understand, consume, debug, version, replay, and operate.
A bad event payload creates confusion. Consumers need to call back to the producer for missing data. Events become tightly coupled to database tables. Important identifiers are missing. Debugging requires searching through multiple systems just to understand what happened.
Event design is architecture design.
An event is a contract between the service that publishes a fact and the systems that react to it.
So the question is:
What should be inside an event?
A good event starts with a clear business fact.
For example:
OrderCreated
PaymentAuthorized
InventoryReserved
SubscriptionCancelled
InvoiceGenerated
GameRoundCompleted
BonusGranted
The event name should describe something meaningful that happened in the domain.
It should not describe an implementation detail.
For example, this is probably too technical:
OrderRowInserted
DatabaseRecordUpdated
PaymentTableChanged
CacheInvalidated
Those may be useful internally, but they are not great domain events.
A consumer should be able to understand the event without knowing the producer's database schema.
A good event says:
Something meaningful happened in the business.
Not:
Something changed in a table.
Events should usually be named in the past tense.
For example:
OrderCreated
PaymentSucceeded
SubscriptionCancelled
InventoryReserved
That makes it clear that the event is a fact.
It already happened.
Compare that with:
CreateOrder
ChargePayment
CancelSubscription
ReserveInventory
Those are commands. They ask for something to happen.
The naming convention helps everyone understand how the message should be handled.
A command can be accepted or rejected.
An event should be treated as a fact that already occurred.
I like events that have a clear envelope.
The envelope contains metadata about the event.
The payload contains the business data.
For example:
{
"eventId": "evt_123",
"eventType": "OrderCreated",
"eventVersion": 1,
"occurredAt": "2026-06-23T10:00:00Z",
"publishedAt": "2026-06-23T10:00:01Z",
"producer": "order-service",
"correlationId": "corr_456",
"causationId": "cmd_789",
"payload": {
"orderId": "order_123",
"customerId": "customer_456",
"totalAmount": 49.99,
"currency": "EUR"
}
}
This structure keeps technical event information separate from business data.
That makes events easier to log, trace, validate, and evolve.
There are a few metadata fields I almost always want.
eventId
eventType
eventVersion
occurredAt
producer
correlationId
causationId
Depending on the system, I may also include:
publishedAt
tenantId
schemaId
traceId
environment
producerVersion
Not every system needs all of these.
But missing metadata often becomes painful later.
It is much easier to include useful metadata from the start than to add it after the first production incident.
Every event should have a unique event ID.
For example:
eventId = evt_123
This ID represents one specific event message.
It is useful for:
Logging
Tracing
Deduplication
Dead-letter queue inspection
Replay
Auditing
Debugging
If a consumer fails, you want to search for the exact event that caused the failure.
Without an event ID, debugging becomes harder.
For example, if a dead-letter queue contains a failed message, the event ID gives you a handle for investigation.
The event type describes what happened.
For example:
OrderCreated
PaymentAuthorized
SubscriptionCancelled
This field is important because consumers often route events based on type.
It should be stable, meaningful, and domain-oriented.
Avoid vague event types like:
Updated
Changed
Processed
Completed
These names do not say enough.
What was updated?
What changed?
What was completed?
A good event type should communicate the business fact clearly.
The event version tells consumers which schema version they are receiving.
For example:
eventVersion = 1
This is important because event schemas evolve.
A consumer may need to handle:
OrderCreated version 1
OrderCreated version 2
OrderCreated version 3
Versioning makes schema changes explicit.
Without an event version, consumers have to guess which shape they received.
That usually leads to fragile code.
I like to distinguish between when the business event occurred and when the message was published.
For example:
occurredAt = when the business fact happened
publishedAt = when the event was published to the broker
Usually these are close together.
But they can differ.
For example, with the Outbox pattern, the event may be recorded during the database transaction and published a few seconds later.
Order was created at 10:00:00
Event was published at 10:00:05
Both timestamps can be useful.
occurredAt is useful for business timelines.
publishedAt is useful for operational latency.
For example, the difference between them can show outbox delay.
The producer tells you which service published the event.
For example:
producer = order-service
This is useful when debugging.
If an event has an invalid payload, unsupported version, or unexpected value, you need to know where it came from.
In larger systems, you may also include the producer version:
producerVersion = 2026.06.23.1
That can help connect an event issue to a deployment.
A correlation ID connects all events, commands, logs, and traces that belong to the same business flow.
For example, an order flow may include:
CreateOrder request
OrderCreated event
ReserveInventory command
InventoryReserved event
AuthorizePayment command
PaymentAuthorized event
OrderConfirmed event
All of these should share the same correlation ID.
That lets you follow the whole process across services.
A simple way to think about it:
correlationId = the whole story
When a user says an order is stuck, the correlation ID helps you reconstruct the story.
A causation ID tells you what caused this event.
For example:
ReserveInventory command caused InventoryReserved event
InventoryReserved event caused AuthorizePayment command
AuthorizePayment command caused PaymentAuthorized event
The causation ID links one step to the previous step.
A simple way to think about it:
causationId = the previous chapter
This is especially useful in sagas and long-running workflows.
It helps you understand why an event exists.
Metadata helps with tracing.
Business identifiers help with understanding.
For example:
orderId
paymentId
customerId
accountId
subscriptionId
invoiceId
reservationId
playerId
gameRoundId
These identifiers should usually be in the payload or metadata, depending on how the system is designed.
If an event is about an order, include the orderId.
If it is about a payment, include the paymentId.
If it belongs to a tenant, include the tenantId.
Consumers, logs, dashboards, and support tools all need these identifiers.
A message without business identifiers is much harder to operate.
This is one of the most important design questions.
Should an event contain only IDs?
Or should it contain a larger snapshot of relevant data?
For example, should OrderCreated look like this?
{
"orderId": "order_123"
}
Or like this?
{
"orderId": "order_123",
"customerId": "customer_456",
"totalAmount": 49.99,
"currency": "EUR",
"items": [
{
"productId": "product_1",
"quantity": 2,
"unitPrice": 19.99
}
]
}
There is no universal answer.
It depends on what the event is for.
But there are trade-offs.
A thin event contains mostly identifiers.
For example:
{
"eventType": "OrderCreated",
"orderId": "order_123"
}
The benefit is that the event is small and simple.
The downside is that consumers may need to call the producer to get more information.
For example, the Email Service may receive OrderCreated, but then it needs to call the Order Service
to get order details.
That creates runtime coupling.
If many consumers call back to the producer, the producer can become a bottleneck.
Thin events can be fine when consumers only need the ID, or when the producer intentionally wants consumers to fetch current state.
But thin events are not automatically better.
They can move complexity from the event payload into synchronous service calls.
A rich event contains the information consumers commonly need.
For example:
{
"eventType": "OrderCreated",
"orderId": "order_123",
"customerId": "customer_456",
"totalAmount": 49.99,
"currency": "EUR",
"items": [
{
"productId": "product_1",
"quantity": 2,
"unitPrice": 19.99
}
]
}
The benefit is that consumers can process the event without calling back to the producer.
This improves decoupling and resilience.
The downside is that the event schema becomes larger and more important.
More fields mean more schema evolution concerns.
Also, if you include too much data, you may expose information consumers do not need.
A good rich event includes enough data to represent the business fact and support likely consumers, but not every internal detail.
Rich events are sometimes called event-carried state transfer.
The idea is that the event carries enough state for consumers to update their own views.
For example, a Reporting Service can build a projection from OrderCreated,
PaymentAuthorized, and OrderCancelled events without constantly calling the Order
Service.
This can be very useful.
It helps consumers remain independent.
But it also means the event contract matters more.
The producer must be careful about field meanings, versioning, and backward compatibility.
A rich event does not mean dumping the entire database row into the event.
Avoid events like this:
{
"eventType": "OrderCreated",
"payload": {
"all_database_columns": "...",
"internal_flags": "...",
"temporary_state": "...",
"debug_values": "..."
}
}
That couples consumers to internal implementation details.
It also increases the chance of leaking sensitive or irrelevant data.
An event should contain information that belongs to the business fact.
Not every field the producer happens to store.
Good event design is selective.
This is worth repeating.
Do not treat your event as a public version of your database table.
Database schemas change for internal reasons.
Events are integration contracts.
If you expose internal models, consumers become coupled to your storage design.
For example, this is not ideal:
{
"table": "orders",
"operation": "insert",
"row": {
"id": "order_123",
"cust_fk": "customer_456",
"amt_net": 41.31,
"amt_tax": 8.68,
"st": "P"
}
}
A better event is domain-oriented:
{
"eventType": "OrderCreated",
"orderId": "order_123",
"customerId": "customer_456",
"status": "PendingPayment",
"totalAmount": 49.99,
"currency": "EUR"
}
Consumers should not need to know that the database column is called cust_fk.
Events should include identifiers that allow consumers to process them safely.
For example, a payment event should include:
paymentId
orderId
amount
currency
A bonus event should include:
bonusGrantId
playerId
amount
reason
An inventory event should include:
reservationId
orderId
productId
quantity
The consumer needs a stable business key to detect duplicate processing.
For example:
Apply payment pay_123 once
Grant bonus bonus_456 once
Reserve inventory reservation_789 once
If the event does not include the right business key, idempotency becomes harder.
If ordering matters, include a version or sequence number.
For example:
{
"eventType": "AccountBalanceUpdated",
"accountId": "account_123",
"version": 17,
"balance": 150.00,
"currency": "EUR"
}
The consumer can then detect stale events.
For example:
Current version: 17
Incoming version: 16
Ignore as stale
Or detect missing events:
Current version: 17
Incoming version: 19
Version 18 is missing
Without a version, the consumer may not know that it processed events in the wrong order.
Events often travel further than expected.
They may be stored in brokers, logs, archives, replay systems, data lakes, and monitoring tools.
That means sensitive data needs care.
Avoid including data unless consumers genuinely need it.
Examples to think carefully about:
Email addresses
Phone numbers
Addresses
Payment details
Identity documents
Personal attributes
Authentication tokens
Session tokens
Internal secrets
Some data should never be in an event.
Some data should be referenced by ID instead.
Some data may need encryption, masking, retention rules, or access controls.
The fact that an event is internal does not mean it is safe to put everything in it.
Sometimes an event contains derived values.
For example:
totalAmount
taxAmount
discountAmount
netAmount
This can be useful because consumers do not have to recalculate business logic.
But it also means the producer owns the calculation.
That can be good.
For example, a Payment Service should probably publish the final authorized amount rather than forcing every consumer to recalculate it.
But be careful with derived fields that can become inconsistent or ambiguous.
If you include them, document what they mean.
For example:
Does totalAmount include tax?
Does it include discounts?
Is it before or after fees?
Which currency is it in?
Field meaning matters as much as field type.
Event fields should be boring and clear.
Bad:
amount
status
date
type
value
data
Better:
totalAmount
paymentStatus
occurredAt
paymentMethodType
bonusValue
Short names can be fine when the context is obvious, but ambiguous names cause problems.
For example, amount in a payment event could mean:
Gross amount
Net amount
Authorized amount
Captured amount
Refunded amount
Fee amount
If the meaning matters, name it clearly.
Future consumers should not have to read producer code to understand the event.
For numeric values, include units.
For money, include currency.
Bad:
{
"amount": 49.99
}
Better:
{
"amount": 49.99,
"currency": "EUR"
}
For duration:
{
"durationMs": 350
}
For distance:
{
"distanceMeters": 1200
}
For percentages, be clear whether the value is:
0.15
15
15%
These details avoid consumer bugs.
Use stable IDs for references.
For example:
{
"customerId": "customer_123",
"productId": "product_456"
}
Display names can change.
For example:
Product name
Customer name
Casino display name
Game title
Sometimes including display names is useful for projections or emails.
But the stable identifier should still be present.
A consumer should not have to identify a product by name.
Names are for humans.
IDs are for systems.
Optional fields are normal.
But consumers need to know which fields are required and which are optional.
For example:
{
"eventType": "PaymentFailed",
"paymentId": "pay_123",
"failureReason": "CardDeclined",
"providerErrorCode": null
}
If providerErrorCode is optional, document that.
Do not make consumers guess.
Also be careful with the difference between:
Field missing
Field present with null
Field present with empty string
These may have different meanings.
If the difference matters, define it.
When an event represents a failure, cancellation, rejection, or manual intervention, include a reason.
For example:
{
"eventType": "PaymentFailed",
"paymentId": "pay_123",
"orderId": "order_456",
"failureReason": "CardDeclined"
}
or:
{
"eventType": "OrderCancelled",
"orderId": "order_456",
"cancelledBy": "system",
"cancellationReason": "PaymentFailed"
}
This is useful for support, analytics, retries, and business reporting.
A state change without a reason often leads to extra investigation later.
An event should be published by the service that owns the fact.
Order Service publishes OrderCreated
Payment Service publishes PaymentAuthorized
Inventory Service publishes InventoryReserved
Billing Service publishes InvoiceGenerated
This ownership matters.
If multiple services publish events about the same fact, consumers may not know which event to trust.
For example, should the Order Service publish PaymentSucceeded?
Usually no.
The Payment Service owns payment state.
The Order Service may publish OrderMarkedAsPaid, but that is a different fact.
Clear ownership prevents confusing event contracts.
Some events describe a full state.
Others describe a change.
A snapshot-style event:
{
"eventType": "UserProfileUpdated",
"userId": "user_123",
"profile": {
"displayName": "Jeroen",
"locale": "nl-BE"
},
"version": 8
}
A delta-style event:
{
"eventType": "UserDisplayNameChanged",
"userId": "user_123",
"oldDisplayName": "J.",
"newDisplayName": "Jeroen",
"version": 8
}
Both can be valid.
Snapshot events are often easier for projections because the consumer can overwrite current state.
Delta events are more precise and can describe exactly what changed.
The choice depends on the use case.
For many integration events, a small snapshot of the relevant state is easier to consume.
For audit or event sourcing, more specific change events may be better.
If every consumer needs to call the producer after receiving an event, the system may not be as decoupled as it looks.
For example:
OrderCreated only contains orderId
Email Service calls Order Service for details
Analytics Service calls Order Service for details
CRM Service calls Order Service for details
Reporting Service calls Order Service for details
Now the Order Service becomes a runtime dependency for all consumers.
If the Order Service is down, consumers cannot process the event.
That may be acceptable in some designs, but it should be intentional.
Events should often contain enough data for common consumers to do their work independently.
An event payload should be readable.
If someone opens a message from a dead-letter queue, they should be able to understand what it represents.
For example:
{
"eventType": "BonusGranted",
"eventVersion": 1,
"occurredAt": "2026-06-23T10:00:00Z",
"payload": {
"bonusGrantId": "bonus_123",
"playerId": "player_456",
"amount": 10,
"currency": "EUR",
"reason": "WelcomeBonus"
}
}
That is understandable.
Compare that with:
{
"t": "BG",
"v": 1,
"p": {
"b": "bonus_123",
"u": "player_456",
"a": 10,
"r": 3
}
}
Compact formats may save space, but they make debugging harder.
There are cases where compact binary formats are useful, but the schema should still be well documented and inspectable through tooling.
A producer should validate events before publishing them.
For example:
Required fields are present
Field types are correct
Enum values are valid
IDs are valid
Amounts include currency
Version is set
Metadata is present
Invalid events should not enter the broker if they can be caught at the producer.
A producer that publishes invalid events pushes complexity downstream.
Consumers should still protect themselves, but producers should be strict about what they emit.
Consumers should ignore unknown optional fields.
They should handle supported versions.
They should avoid breaking when new fields are added.
But that does not mean consumers should accept everything.
A consumer should reject or isolate events that are unsafe to process.
For example:
Missing required business ID
Unsupported event version
Invalid amount
Unknown critical enum value
Invalid state transition
The consumer should fail clearly, with enough context for debugging.
Tolerant does not mean silent.
A good event should have documentation.
At minimum:
Event name
Business meaning
Producer
When it is published
Payload example
Required fields
Optional fields
Version
Compatibility rules
Known consumers
Retention and replay expectations
This documentation does not need to be heavy.
But the meaning of the event should not live only in one developer's head.
Events are shared contracts.
Shared contracts need shared understanding.
When designing an event, I like to ask:
What business fact does this event represent?
Who owns this fact?
Is the event named in the past tense?
Which consumers are likely to use it?
Does it contain the IDs needed for idempotency?
Does it contain the version needed for ordering?
Does it include correlation and causation IDs?
Can consumers process it without calling back?
Does it avoid leaking internal database details?
Does it avoid unnecessary sensitive data?
Are field names clear and unambiguous?
Can the schema evolve safely?
Is the event documented?
These questions catch many design problems early.
If I had to explain event payload design in an interview, I would say:
An event should represent a clear business fact, usually named in the past tense, like OrderCreated
or PaymentAuthorized. I would avoid exposing internal database changes as public events because
events are contracts between services.
I like using an event envelope with metadata such as eventId, eventType,
eventVersion, occurredAt, producer, correlationId, and
causationId. The payload should contain the business identifiers and data needed by consumers.
The amount of data depends on the use case. Thin events with only IDs are simple, but they may force consumers to call back to the producer, creating runtime coupling. Rich events can help consumers work independently, but the schema needs careful versioning and should not expose unnecessary internal or sensitive data.
I would include stable business IDs for idempotency, version or sequence numbers when ordering matters, clear units and currencies for numeric fields, and reason fields for failures or cancellations. The goal is that consumers can process the event safely, understand its meaning, and evolve with the schema over time.
Event payloads are easy to underestimate.
At first, they look like just JSON messages.
But over time, they become contracts, audit records, replay inputs, debugging tools, and integration points between teams.
A good event payload makes the system easier to operate.
A bad event payload creates coupling, confusion, and fragile consumers.
Design events around business facts.
Include the metadata needed to trace them.
Include the identifiers needed to process them safely.
Include enough data for consumers to do their work, but not so much that the event becomes a database dump.
And most importantly, treat the event as something other systems will rely on.
Because they will.
This post is part of my Backend Architecture Notes series.