Skip to content
AITroveRead. Build. Understand.
Make this comfortable

Spring Boot Transaction Replay and Outbox Handoff

Last updated: 5 Oct 20268 min read
tutorial
IntermediateBy AITrove Editorial

An approval command crosses a database transaction and a separate delivery system. The case state, operation identity, public result, and outbox intent should be committed together in the database. A unique operation ID with a command digest lets an exact retry recover the result after an HTTP response is lost; the same ID with changed input is a conflict. Current assignment and expected revision must be checked inside the transaction that writes the case, with a locking or optimistic-concurrency rule chosen for the database. A transaction-bound event listener can run after commit, which prevents a worker from seeing uncommitted state. That timing does not make a separate broker handoff atomic with the database commit. A durable outbox row and independent relay remain necessary when losing a notification is unacceptable.

Working case

Reviewer 47 approves permit 62 at revision 5. The database commits revision 6, but the browser connection drops before receiving success. The browser retries the same operation ID. Without a stored result it may create a second audit entry or send another email. Meanwhile the process crashes after commit and before its after-commit callback enqueues work. A correct service stores the command digest and result alongside the case update and a pending outbox event. The exact retry returns the saved result, a changed reason under that ID fails, and a relay discovers the pending event after restart. A competing reviewer with expected revision 5 receives a conflict rather than silently overwriting revision 6.

Implementation boundary

Java
@Service
class PermitApprovalService {
  private final PermitRepository permits;
  private final ApprovalOperationRepository operations;
  private final ApprovalOutboxRepository outbox;

  @Transactional
  public ApprovalView approve(Long permitId, ApprovePermitCommand command) {
    PermitCase permit = permits.lockById(permitId).orElseThrow();
    ApprovalOperation prior = operations.find(permitId, command.operationId());
    if (prior != null) return prior.sameCommand(command)
        ? prior.publicResult() : conflict();
    requireCurrentReviewer(permit);
    requireRevision(permit, command.expectedRevision());
    permit.approve();
    ApprovalOperation saved = operations.saveOutcome(permit, command);
    outbox.savePending(saved.eventKey(), permit.getId());
    return saved.publicResult();
  }
}

Keep the transactional service method on a Spring-managed bean reached through its proxy; a direct same-bean call can bypass @Transactional advice. Lock the case row or use an optimistic version column, and recheck current assignment and legal transition in the transaction. Put a unique constraint on operation identity scoped to the intended command domain. Store a digest of all meaningful command fields and a public result. Insert an outbox row with a stable event key before commit. After commit, a listener may wake the relay, but the relay must also scan pending rows because the wake-up can be lost. Claim rows with a lease or database-supported locking, retry under the same event identity, and mark delivered only after the provider or broker outcome is known. Ambiguous external outcomes require reconciliation or provider idempotency support.

Cost and boundaries

A short row lock serializes approvals of the same case and can increase wait time under contention; optimistic locking avoids holding an explicit lock but requires conflict handling. The unique operation and outbox records add writes and retained storage proportional to successful commands. A relay scan and lease claim add background database work, while an event hint can reduce average notification delay. Provider retries may duplicate effects if the destination does not recognize the stable event key. Keep the transaction free of network calls so a slow provider cannot hold the case lock. Measure lock wait, duplicate-key races, pending outbox age, retry count, delivery lag, and reconciliation backlog. A clean unit test cannot prove power-loss behavior, so operational drills must inspect persisted rows after process interruption.

Failure trace

Race two exact requests with one operation ID and assert one case transition, one saved result, and one outbox row. Reuse the ID with a different reason and require conflict. Drop the HTTP response after commit, retry, and compare the recovered result to the first transaction. Revoke assignment just before the write and verify a stale earlier GET does not authorize it. Force rollback after changing case state but before outbox insertion and confirm neither change survives. Kill the web process after commit but before after-commit enqueue; the independent relay must find the pending row. Deliver the same outbox event twice to a test sink and inspect its idempotency or reconciliation behavior. Repeat against the actual production database type when lock semantics matter.

Verification

  • Case, operation result, and outbox intent commit together.
  • An exact replay recovers the saved outcome; changed input conflicts.
  • A pending row survives a lost after-commit queue hint.

Practice drill

Build an ApprovePermit service with permit ID, reviewer identity, operation ID, expected revision, and bounded reason. Persist case revision, operation digest and result, and outbox event in one transaction. Use a unique database constraint to resolve concurrent exact retries across processes. Add a relay that claims pending rows with a lease, sends under a stable event key, and marks completion only after a confirmed outcome. Test lost response, changed-payload replay, competing revision, rollback, lost wake-up, lease expiry, and ambiguous provider timeout. Inspect database rows and delivery history after each drill; a successful HTTP status alone is not evidence that external work completed.

Decision note

The database owns one approval and one delivery intent; after-commit callbacks only speed a relay that can recover independently.

Common Mistakes

  • Calling a @Transactional method only through same-bean self-invocation.
  • Sending provider work inside the case transaction.
  • Treating an after-commit listener as a durable cross-system transaction.

Related lessons

Spring Boot Web Service Boundaries; Spring Boot Security Chain and Object Permission; Spring Boot MVC Validation and Problem Contracts; Spring Boot JPA Scope, Fetch, and Page Cost; Spring transactional methods: call paths and rollback assumptions; Spring transaction events: run a listener after commit without claiming durability; Spring transactional outbox: commit a receipt and event row together; Spring receipt outbox command: record intent without claiming delivery; Background Workflow Reliability.

Apply and check

Build Project: Spring Boot Permit Service Boundaries and review Web Development: Spring Boot service contracts.

web-tech
web-development
Storage details