Why "save, then publish" breaks
Save the order, then publish an event. A crash between those two lines is enough to leave an order with no confirmation email, or a customer charged twice. This post follows an order through the system to see what to guard, and where.
Wherever it crashes, only one side remains
When an online store takes an order, it saves the order to the database and publishes an "order created" event to a message broker. Stock reservation and the confirmation email run off that event.
The problem is that the database and the broker are separate systems that cannot commit together. A deploy or a node failure can stop the process between any two lines.
- Database: Saves the order
- App: A deploy stops the process
- Broker: The event is never sent
- App: Neither stock nor email moves
| Approach | Where it crashes | Result |
|---|---|---|
| Save, then publish | After saving, before publishing | The order exists but the event is lost |
| Publish, then save | After publishing, before saving | An event for a nonexistent order goes out |
| Publish inside the transaction | After publishing, the COMMIT fails | The sent event cannot be recalled |
Whatever the order, one failure leaves only one side done. A distributed transaction spanning the database and the broker is unavailable or too heavy in most setups.
The outbox: write what you will send in the same transaction
Stop publishing the event directly. Write the business data and "the event to be sent" in the same transaction on the same database. Either both remain or neither does.
BEGIN;
INSERT INTO orders (id, user_id, total, status)
VALUES ('ord_123', 'u_9', 4800, 'pending');
INSERT INTO outbox (id, aggregate_id, type, payload)
VALUES ('evt_456', 'ord_123', 'OrderCreated',
'{"orderId": "ord_123", "total": 4800}');
COMMIT;A separate process, the relay, reads the outbox table and does the sending. There are two ways to build it.
| Aspect | Polling | CDC (reading the change log) |
|---|---|---|
| How | Periodically read unsent rows, mark them once sent | Stream the WAL or binlog to the broker (e.g., Debezium) |
| Moving parts | Few | Adds a CDC tool |
| Lag | The polling interval | Nearly immediate |
| Order | Can break when relays split the work | Easier to keep, since it reads in commit order |
-- Take unsent rows without competing with other relays
SELECT id, aggregate_id, type, payload
FROM outbox
WHERE sent_at IS NULL
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
-- Mark them once sent. A crash here means they are sent again after restart
UPDATE outbox SET sent_at = now() WHERE id = ANY($1);Exactly-once stops at the system boundary
Receivers have the same problem. A consumer processes a message and then commits how far it has read (its offset). If it crashes in between, it processes the same message again after restarting.
Kafka transactions can give exactly-once when reading from Kafka and writing to Kafka. They do not cover side effects outside it, such as calling a payment API or writing to another database.
So accept at-least-once delivery and drop duplicates per side effect. For a side effect in your own database, record the processed message ID in the same transaction.
BEGIN;
INSERT INTO processed_messages (message_id) VALUES ('evt_456')
ON CONFLICT (message_id) DO NOTHING;
-- Zero rows inserted means already processed: commit without doing anything
UPDATE inventory SET reserved = reserved + 1 WHERE sku = 'sku_1';
COMMIT;
-- Advance the offset after the COMMITFor outside side effects, pass the order ID as the idempotency key
A payment API lives outside your database, so that method cannot protect it. If the process crashes after charging but before recording the message, a charge with no record remains.
Instead, pass a business ID such as the order ID to the payment API as the idempotency key. If redelivery calls the charge for the same order again, the provider returns the first result.
If your own API accepts idempotency keys
Conversely, if your API is the one being retried, storing the key is not enough. Decide how to respond in these three situations.
| Situation | Response |
|---|---|
| Two requests with the same key arrive together | A unique constraint on the key lets only one proceed |
| The first request is still in progress | Return 409 and let the client retry later |
| Same key, different contents | Return 422 as a misuse |
Store the result (status code and body) in the same transaction as the business data, and return it for later requests with the same key. Keep keys longer than clients can keep retrying. The Idempotency-Key header draft under discussion at the IETF defines responses for these same situations.
Across services, tie the steps together with a saga
When orders, payments, and inventory each have their own service and database, one transaction cannot cover them. A saga commits each step in its own service, and if a later step fails, it undoes the completed steps with compensating transactions.
- Orchestrator: Create the order as "pending"
- Payment service: Authorize the payment
- Inventory service: Fail to reserve stock
- Orchestrator: Request compensation (order ID as the key)
- Payment service: Void the authorization
- Orchestrator: Mark the order "cancelled"
The parts built so far become the saga's parts. Send each step's command through the outbox, let receivers drop duplicates by message ID, and make compensation calls idempotent too.
Unlike a database rollback, intermediate states (payment authorized, stock not yet reserved) are visible to other processes. Make "pending" an explicit state so unconfirmed orders never ship.
A checklist for reviews
- Is anything written to the database and published as separate steps?
- Does every receiver produce the same result when a message arrives twice?
- Is the processed-message record written in the same transaction as the side effect?
- Do writes to external APIs pass a business ID as the idempotency key?
- Does your own write API define responses for simultaneous, in-progress, and mismatched requests?
- Does every saga step have a compensation, with irreversible steps after the pivot?
When asked, "How do you keep orders and events consistent?"
- A dual write leaves only one side done if you crash in between
- Write the business data and the event in one transaction with an outbox
- Delivery is at-least-once, so make receivers idempotent by message ID
- Pass the order ID to the external payment provider as the idempotency key
- Across services, use a saga, with irreversible steps after the pivot
Saying "we make at-least-once delivery look like once with idempotent processing" shows you understand the boundary far better than "we use exactly-once delivery."