The orders service saved an order in PostgreSQL, and at that moment the process crashed. The event OrderCreated never reached the broker: inventory did not reserve the item, Leyla got no email, but the order is in the database. This is the dual-write problem, and it spoils the life of almost every event-driven system. In this lesson we see how the transactional outbox solves it, how to make a consumer idempotent, how to change the format of events without breaking readers, how sagas work and why you should show the user an "in progress" status.

Events and commands

Messages come in two kinds, and the design depends on the choice. A command is a request: "do this". An event is a fact: "this happened".

CommandEvent
Meaning"reserve the item""the item is reserved"
Namea verb: ReserveStockpast tense: StockReserved
Addresseeone known serviceunknown: subscribers
Refusalthe receiver may refuseit already happened, no refusal

Events are named in the past tense: OrderCreated, OrderPaid, PaymentFailed. A name like SendEmail is a command in the costume of an event; it ties the sender to a particular receiver. An event always lies in an envelope with an id, a type, a version and a time:

json
{
  "id": "0f6c1c5e-3d0a-4a4b-9c58-5f6c1f1d9a10",
  "type": "OrderPaid",
  "version": 1,
  "occurredAt": "2026-10-04T10:15:00.000Z",
  "data": { "orderId": 7, "total": 87, "currency": "AZN" }
}

Put into data only what readers need: identifiers and key values, not the whole order with personal data. The fields id and version will be useful later.

The dual-write problem

The service saved the order in the database but crashed before publishing: the event is lost and other services learn nothing
The service saved the order in the database but crashed before publishing: the event is lost and other services learn nothing

An order has to be written to the database and announced to the broker. These are two different systems with no common transaction between them. Whatever you choose, a window for a failure remains:

  • the database first, then the broker: we crashed in between — the event is lost, the order has no consequences;
  • the broker first, then the database: the database refused — a phantom event about an order that does not exist.

Retries (retry) do not help: the process itself crashes, not a call. Distributed transactions between a database and a broker are rarely used: they are complex and slow.

Transactional outbox

One transaction writes the order and the event into the outbox table, and the relay reads it and publishes the events to the broker
One transaction writes the order and the event into the outbox table, and the relay reads it and publishes the events to the broker

The idea is simple: the broker cannot join a transaction, so we write the event to the database. In the same transaction as the order we insert a row into the outbox table. Either both records are saved or neither. A separate relay process reads unpublished rows, sends them to the broker and marks them as published. The Prisma 7 models: