Read time: ~

Transactional Outbox and Saga Patterns

Solve the dual-write problem with the transactional outbox, and coordinate multi-service workflows with saga choreography and compensation.

Prerequisite:Event-Driven Microservices with RabbitMQYou’ll need: A relational database and the Spring AMQP setup from earlier modules.

Two hard problems appear once events drive real business state: keeping your database and your published events consistent, and coordinating a workflow that spans services without distributed transactions. The outbox pattern solves the first; sagas solve the second.


What you’ll be able to do after this module

  • Explain the dual-write problem and why confirms alone do not solve it.
  • Implement the transactional outbox pattern with Spring.
  • Coordinate a multi-service workflow with a saga.
  • Design compensating actions to undo partial work.

1. The dual-write problem

A service often must do two things when state changes: write to its database and publish an event. These are two separate systems with no shared transaction, so a crash between them leaves you inconsistent.

flowchart TD
    Start["Order Service handles POST /orders"]
    DB["Write order to DB"]
    Pub["Publish OrderCreated"]
    Start --> DB --> Pub
    DB -.->|"crash here"| Bad1["DB updated, event never sent"]
    Pub -.->|"crash / publish fails"| Bad2["event sent, DB not committed"]

Publisher confirms tell you the broker accepted a publish, but they cannot tie that publish to your database commit. You need both writes to succeed or fail together.


2. The transactional outbox

Instead of publishing directly, write the event into an outbox table in the same database transaction as the business change. A separate relay reads the outbox and publishes to RabbitMQ. Because the business row and the outbox row commit together, they can never diverge.

flowchart LR
    subgraph tx [One DB transaction]
        OrderRow["orders row"]
        OutboxRow["outbox row (OrderCreated)"]
    end
    Relay["Outbox relay"]
    Broker["RabbitMQ"]
    tx --> Relay
    Relay -->|"read unpublished, publish"| Broker
    Relay -->|"mark published"| OutboxRow
@Transactional
public void createOrder(CreateOrder cmd) {
    Order order = orderRepository.save(new Order(cmd));
    outboxRepository.save(new OutboxEvent(
            order.getId(), "OrderCreated", toJson(order)));
    // both rows commit atomically; no publish here
}

A relay then drains the outbox and publishes with confirms:

@Scheduled(fixedDelay = 500)
public void relay() {
    for (OutboxEvent e : outboxRepository.findUnpublished(100)) {
        rabbitTemplate.convertAndSend("orders.topic", e.routingKey(), e.payload(),
                new CorrelationData(e.getId().toString()));
        outboxRepository.markPublished(e.getId()); // after confirm
    }
}

Key properties:

  • The relay may publish a message more than once (it crashes after publishing, before marking). That is fine because consumers are idempotent, per Idempotency and Duplicates.
  • Polling is the simplest relay; change-data-capture (reading the DB log) is a higher-throughput alternative.

3. Sagas: workflows without distributed transactions

A business process like “place order” spans services (order, inventory, payment). You cannot wrap them in one ACID transaction. A saga breaks the process into local transactions, each publishing an event that triggers the next step, with compensating actions to undo previous steps if a later one fails.

sequenceDiagram
    participant Order as Order Service
    participant Inventory as Inventory Service
    participant Payment as Payment Service

    Order->>Inventory: OrderCreated
    Inventory->>Inventory: reserve stock (local tx)
    Inventory->>Payment: StockReserved
    Payment->>Payment: charge card (local tx)
    alt payment succeeds
        Payment->>Order: PaymentCompleted
        Order->>Order: mark order CONFIRMED
    else payment fails
        Payment->>Inventory: PaymentFailed (compensate)
        Inventory->>Inventory: release reserved stock
        Payment->>Order: OrderFailed
        Order->>Order: mark order CANCELLED
    end

This is choreographed saga: each service reacts and emits. An orchestrated saga would use a coordinator issuing commands and tracking state, which is easier to trace but adds a central component (see the previous module).


4. Designing compensations

A compensation is the business-level undo of a completed step. It is not a database rollback (that step already committed); it is a new action that offsets the effect.

Forward actionCompensation
Reserve stockRelease stock
Charge cardRefund charge
Send confirmationSend cancellation notice

Guidelines:

  • Compensations must be idempotent, since the triggering event can arrive more than once.
  • Some actions cannot be truly undone (an email was sent). Compensate with a follow-up action rather than pretending it did not happen.
  • Track saga state (which steps completed) so you know what to compensate. This is often an outbox-backed state machine in the initiating service.

Caution: Sagas give you eventual consistency, not immediate consistency. The order is briefly PENDING across services. Design the user experience and any read models to expect that window.


Checkpoint

You should now be able to:

  • Explain the dual-write problem and why confirms do not solve it.
  • Implement a transactional outbox with a relay.
  • Coordinate a workflow as a choreographed saga.
  • Design idempotent compensating actions and reason about eventual consistency.