DEV Community

Cover image for Distributed Transactions in Java: Why I Skipped the Outbox Pattern
William Nogueira
William Nogueira

Posted on

Distributed Transactions in Java: Why I Skipped the Outbox Pattern

Picture a payment flowing through a system: authorize the funds, capture the payment, post it to the ledger. Three steps, three services, and no way to wrap them in a single @Transactional, each service has its own database, and the network sits between them.

Now the interesting part: the authorization succeeds, and the capture fails. You've reserved money on a customer's card for a payment that will never happen. Nothing crashed, no exception went unhandled, every service did its job. The transaction is what broke.

This is the problem the Saga pattern exists for, and I built an orchestration-based saga coordinator in Spring Boot to explore it properly, compensation, retries, crash recovery, the works. In this article I'll focus on the most controversial decision I made along the way: I didn't use the Transactional Outbox pattern, the thing every microservices reference architecture tells you to use. I'll show you why, and when you still should.

The Saga Pattern in 60 Seconds

A saga replaces one impossible distributed transaction with a sequence of local ones. Each step has a compensation: an action that semantically undoes it. If step 3 fails, you run the compensations of steps 2 and 1, in reverse, and the system ends up consistent again.

Saga Flowchart

I chose orchestration over choreography: a central engine owns the state machine, so "where is my payment?" has one answer and one place to attach timeouts, retries, and manual intervention. The orchestrator talks to participants exclusively over RabbitMQ command and reply queues: never in-process, because lost replies and duplicate deliveries are the whole point of the exercise.

The Textbook Problem: The Dual Write

Here's the trap every saga implementation has to deal with. When the orchestrator advances a step, it must do two things:

  1. Write the new state to its database ("step 2 is now RUNNING")
  2. Publish the command to the broker ("capture the payment")

These are two different systems, so you can't commit them atomically. Publish first and the DB write can fail: you've commanded a capture you have no record of. Commit first and the process can crash before publishing: your database says a command is in flight that never left the building.

The textbook answer is the Transactional Outbox: write the outgoing message into an outbox table in the same DB transaction as the state change, and let a relay process publish from that table afterwards. It works. It's also a second table, a polling relay, ordering concerns, cleanup jobs, and one more moving part that can fail in its own creative ways.

Before adding all that, I stopped and asked what the outbox actually buys here.

The Realization: My Saga Table Was Already an Outbox

Look at what the orchestrator persists when it dispatches a step:

public void markDispatched(Instant when) {
    transitionTo(StepStatus.RUNNING);
    this.attempts++;
    this.dispatchedAt = when;
}
Enter fullscreen mode Exit fullscreen mode

A step in RUNNING with a dispatched_at timestamp and no reply is the record of a command that must be in flight. That's exactly the information an outbox row carries and I was about to persist it twice.

So instead, commands leave after the state is durable, using a post-commit hook:

private void dispatchAfterCommit(StepCommand command, int attempt) {
    TransactionUtils.executeAfterCommit(
            () -> commandDispatcher.dispatch(command),
            () -> log.error("dispatch of step '{}' for saga {} failed; "
                    + "the armed timeout will fail the step",
                    command.stepName(), command.sagaId()));

    // armed even if the dispatch above fails: a command that never left is
    // indistinguishable from a reply that never came back, and both end here
    TransactionUtils.executeAfterCommit(
            () -> stepTimeoutScheduler.scheduleTimeoutCheck(
                    command.sagaId(), command.stepId(), attempt));
}
Enter fullscreen mode Exit fullscreen mode

That comment is the entire architecture in one sentence. If the process crashes between commit and dispatch, the exact window the outbox exists for, the command is simply gone. And from the orchestrator's perspective, a command that never left looks identical to a participant that never answered. I already needed timeout handling for dead participants. Why build a second recovery mechanism for a failure that's indistinguishable from the first?

Timeouts Are the Relay

Every dispatched command arms a timeout. If no reply lands in time, the step is retried with linear backoff, and only silence retries. An explicit business failure ("card declined") goes straight to compensation, because retrying a decision is pointless:

if (step.getStatus() == StepStatus.RUNNING) {
    if (step.getAttempts() < sagaProperties.stepMaxAttempts()) {
        retryDispatch(saga, step, step.getName());
        return;
    }

    step.transitionTo(StepStatus.FAILED);
    beginCompensation(saga);
}
Enter fullscreen mode Exit fullscreen mode

Notice the retries are attempt-guarded: each timeout check carries the attempt number it was armed for, so a stale timer from attempt 1 can't kill a step that's already on attempt 2.

Crash Recovery: One Sweep, Same Path

The timers live in memory, so a restart loses them. That's fine, they were never the source of truth. A recovery sweeper runs at startup and on a schedule, re-deriving every deadline from persisted state and routing stragglers through the same timeout handler:

@Scheduled(fixedDelayString = "${saga.sweep-interval}")
public void sweep() {
    var now = clock.instant();
    var inFlight = sagaStepRepository.findByStatusIn(
            List.of(StepStatus.RUNNING, StepStatus.COMPENSATING));

    for (var step : inFlight) {
        var deadline = step.getDispatchedAt()
            .plus(sagaProperties.stepTimeout().multipliedBy(step.getAttempts()));
        if (now.isAfter(deadline)) {
            sagaOrchestrator.onStepTimeout(
                    step.getSaga().getId(), step.getId(), step.getAttempts());
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

One recovery story, two triggers. And because the handler is state-guarded and attempt-guarded, and the saga row carries an optimistic lock, the sweep is safe to run concurrently, five pods running it against one database would just be four no-ops and a winner.

Idempotency Without a Dedupe Table

The same thinking killed the message-deduplication table. RabbitMQ redelivers, participants retry, timers race replies, but a reply can only act on a step that's in exactly the right state:

switch (step.getStatus()) {
    case RUNNING -> onStepReply(saga, step, reply);
    case COMPENSATING -> onCompensationReply(saga, step, reply);
    default -> log.warn("ignoring duplicate or late reply for saga {} step {}",
            reply.sagaId(), reply.stepId());
}
Enter fullscreen mode Exit fullscreen mode

A duplicate success reply finds the step already COMPLETED and falls into the default arm. A reply that arrives after the timeout already failed the step? Same. Two threads processing the same reply concurrently? One commits, the other gets an OptimisticLockException and rolls back. The state machine is the dedupe.

The Trade-off: When You Should Still Use an Outbox

What I gave up: recovery latency. An outbox relay republishes a lost message within seconds. My design waits out the step timeout first: bounded and tunable, but slower. For a payment saga where the participant itself might take seconds to respond, that's acceptable. For a system where a lost message must be recovered near-instantly, it isn't.

Where the outbox genuinely wins: when you're publishing events rather than commands: fire-and-forget notifications where no reply will ever come back to tell you something was lost. No reply channel means no timeout, and no timeout means my whole recovery story evaporates. If I ever split the participants into real services that publish their own domain events, they will get outboxes.

The pattern isn't wrong. I just didn't have the problem it solves: my state machine already solved it as a side effect of existing.

Conclusion

The saga engine ended up with exactly one recovery mechanism: persist intent first, dispatch after commit, and let timeouts plus a sweeper re-drive anything the real world loses. Fewer moving parts, one code path to test: and the test suite hammers it with duplicate replies, dead participants, and simulated crashes against real Postgres and RabbitMQ containers.

Patterns are answers to specific problems. Before you add one, check whether your system already answers the question: sometimes the table you have is the table the pattern would have given you.

The full implementation, compensation, manual intervention endpoints, metrics, the whole state machine is on GitHub.

Top comments (0)