Transactional Outbox: What to Put in Events
The transactional outbox is not a general distributed transaction. It is a local consistency pattern: commit the business change and the message record together, publish later, and design the event around what consumers actually need.
The pattern solves one specific failure mode: a service commits its own database change, then crashes or times out before publishing the message that other services rely on.
For an order payment, the outbox version keeps the local change and the future message in one commit:
mark order as paid
insert outbox row for OrderPaid
commit both rows together
A background worker later reads the outbox row and publishes OrderPaid.
That does not make the whole system synchronous or perfectly consistent. It only makes the local write and the message record commit together.
This article is about publishing integration events from normal database-backed services, not using events as the source of truth.
Eventual consistency is the tradeoff
Moving data between services usually means accepting a delay:
producer commits change
outbox message is published later
consumer handles message later
consumer read model catches up later
That delay is not a bug in the pattern. It is the cost of decoupling services.
If another service keeps a local projection of order data, that projection will sometimes be behind the order service. The design has to account for that.
The important question is:
does the consumer need current state, or does it need the facts from when the event happened?
That question decides what the event should contain.
Notification events
A notification event says:
something changed; go look if you care
Example:
{
"type": "OrderChanged",
"orderId": "ord_123"
}
This can be fine. It keeps messages small and avoids duplicating payload fields.
Notification events work well when:
- consumers only need to invalidate or refresh something
- current state is what matters
- consumers already have their own read model
- the full payload would be large or sensitive
The tradeoff is that consumers now need to fetch more data. That creates more producer API or database load, more network calls, and more runtime coupling.
It also changes what the consumer sees. If a consumer receives OrderChanged
and fetches the order later, it sees the current order, not necessarily the
state that existed when the event was written.
Event-carried facts
An event-carried fact says:
this happened
Example:
{
"type": "OrderPaid",
"orderId": "ord_123",
"userId": "usr_456",
"amount": 4999,
"currency": "SEK",
"paidAt": "2026-05-29T10:15:00Z"
}
The event includes the facts consumers need to process the thing that happened.
This is useful when consumers need to know what was true when the event
happened. A billing, fulfillment, or email service reacting to OrderPaid
probably cares about the amount, currency, and payment time.
If the consumer only gets an order id and fetches later, it may see a later state:
OrderPaid event is written
Order is refunded before consumer fetches
Consumer fetches current order and sees refunded state
That may be correct for some workflows. It is wrong if the consumer needed to handle the payment fact.
Do not send the whole entity by default
Event-carried facts do not mean every event should contain the entire entity.
This is usually too much:
OrderPaid -> full Order object with every field
The consumer does not need every internal detail of the producer’s model. Large payloads increase broker/storage cost, make schema evolution harder, and couple consumers to fields they should not care about.
A better default is:
ids + event-time facts needed to handle the event
For OrderPaid, that might be order id, user id, amount, currency, and paid
time. Shipping address is not part of the payment fact unless the consumer
actually needs it for that workflow.
Performance tradeoffs
The payload choice moves cost around.
notification event -> smaller message, more follow-up reads
event-carried facts -> larger message, fewer follow-up reads
full entity snapshot -> largest message, strongest schema coupling
Notification events can be cheap for the broker but expensive for the producer if many consumers immediately fetch details.
Event-carried facts duplicate some data, but they reduce follow-up reads and make replay more predictable.
Full snapshots can be useful for rebuilding read models, but they should be a deliberate choice, not the default.
There is no universal answer. The right shape depends on:
- fan-out
- payload size
- producer read capacity
- consumer needs
- replay requirements
- data sensitivity
- how much coupling the system can tolerate
Consumers still need idempotency
The outbox makes publishing more reliable. It does not make consumers exactly once.
Messages can be published more than once. Consumers can crash after doing work but before recording success. Brokers can redeliver.
Consumers should be safe to run again:
event already processed -> return success
read model already updated -> return success
email already queued -> do not queue it again
That usually means storing processed message ids, using unique constraints, or making updates naturally idempotent.
Rule of thumb
Use notification events to say:
go look if you care
Use event-carried facts to say:
this happened
Do not force consumers to fetch basic event facts every time. Do not publish the whole entity by default either.
For most integration events, a good starting point is:
event name + ids + the small set of facts needed to handle the event correctly
The outbox gets the message out reliably. The event payload decides how useful that message is.