Order Processing With Sagas: Consistent Payment, Stock and Delivery
Advanced4 minInnovialabs editorial team
In short
A saga splits an order into local steps with compensating actions, while an outbox and an order state machine ensure no event is lost and no order gets stuck in a contradictory state.
How sagas solve consistency
When payment, inventory and shipping are separate services or external systems, there is no single transaction covering them all. A saga is a chain of local transactions where each step has a compensating action: if step 3 fails, steps 2 and 1 are undone in reverse order.
A typical order saga:
| Step | Action | Compensation |
|---|---|---|
| 1 | Create order as pending | Cancel order |
| 2 | Reserve stock | Release reservation |
| 3 | Charge payment | Refund |
| 4 | Create shipment | Cancel shipment |
| 5 | Confirm order | — |
The result is eventual consistency: the system may briefly sit in an intermediate state, but it always reaches a correct final one.
Why distributed orders break
- Partial success. The card was charged, but the reservation wasn't created because the warehouse timed out.
- Dual writes. A service saved the order but crashed before publishing the event, so nobody else knows about it.
- Retries. The network retried a request and the payment went through twice.
- Unknown outcome. A timeout is not a failure — the payment may have succeeded.
- Event ordering. A cancellation arrived before the confirmation.
Two-phase commit (2PC) across external APIs, payment providers and couriers is usually not available, which is why sagas exist.
Orchestration vs choreography
Orchestration — a central coordinator stores the saga's state and calls services one by one.
Choreography — services react to each other's events: OrderCreated → inventory reserves → StockReserved → payment charges, and so on.
| Criterion | Orchestration | Choreography |
|---|---|---|
| Where the process logic lives | In one place | Spread across services |
| Observability | Easier: saga state is visible | Requires event tracing |
| Coupling | Services depend on the orchestrator | Services depend on events |
| Best for | Long processes with branching | Short chains of 2-3 steps |
For an order with payment, stock and delivery, orchestration is usually easier to maintain.
The order state machine
An order needs explicit statuses and allowed transitions. A forbidden transition is an error, not a silent update.
pending -> stock_reserved -> paid -> shipping_created -> confirmed
pending | stock_reserved | paid -> compensating -> cancelled
This guards against races: if PaymentSucceeded arrives for an order already cancelled, the system doesn't revive it — it triggers a refund.
Transactional outbox
To avoid losing events on a crash, the state change and the event are written in the same local transaction: to the orders table and to an outbox table. A separate process reads the outbox and publishes messages to the broker.
BEGIN;
UPDATE orders SET status = 'stock_reserved' WHERE id = $1;
INSERT INTO outbox (aggregate_id, type, payload)
VALUES ($1, 'StockReserved', $2);
COMMIT;
The outbox gives at-least-once delivery, so every consumer must be idempotent: store processed message ids or pass an idempotency key to the payment API.
Failure scenarios
- Payment declined. Compensate: release stock, move the order to
cancelled, notify the customer. - Payment timeout. Don't cancel right away — query the provider for status or wait for the webhook, then decide.
- Courier API down. Retry with backoff; after the limit, hand off to manual handling rather than auto-refunding.
- A compensation fails. Retry until it succeeds and raise an alert; compensations must be idempotent.
- Duplicate message. An idempotent handler ignores the repeat.
Common mistakes
- Calling external APIs inside a database transaction.
- Treating a timeout as a failure.
- No manual intervention path for stuck sagas.
- Not logging saga steps with a shared correlation id.