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.