What is the Transaction Outbox Pattern and how does it work?
masterThe Transaction Outbox pattern solves the problem of inconsistent state when a service needs to update a database and notify other services (e.g., via a message queue) in a single atomic operation.
In a standard @Transactional method, the database update and the message queue call are not part of the same transaction. If the message queue call succeeds but the database transaction fails, the system becomes inconsistent.
transaction-outbox provides a way to 'buffer' these external calls within your local database transaction. When you use the TransactionOutbox.schedule(Class<T> serviceClass) method, it returns a proxy. Any method calls made on this proxy are serialized and written to a database table (defaulting to TXNO_OUTBOX) as part of your existing database transaction.
Lifecycle of an outbox event:
- Transaction Phase: The proxy call is recorded in the
TXNO_OUTBOXtable within the same transaction as your business logic. If the transaction rolls back, the outbox entry is also rolled back. - Immediate Execution: Once the transaction successfully commits, a thread attempts to make the real call to the target service asynchronously.
- Retry/Mop-up: If the call fails or the application crashes, a background worker re-attempts the call a configurable number of times.
- Dead Letter Queue: If all retries fail, the request is blocked (similar to a dead letter queue) for manual investigation and can be unblocked once the underlying issue is resolved.
Note: For this to ensure eventual consistency, both your local database updates and your downstream event handlers must be idempotent, as messages may be delivered multiple times during retries.
@PUT
@Path("/sales/{id}")
@Transactional
public void createWidget(@PathParam("id") SaleId saleId, Sale sale) {
saleRepository.save(saleId, sale);
// This proxy captures the call and writes it to the outbox table in the current transaction
MessageQueue proxy = transactionOutbox.schedule(MessageQueue.class);
proxy.postMessage(StockReductionEvent.of(saleId, sale.item(), sale.amount()));
proxy.postMessage(IncomeEvent.of(saleId, sale.value()));
}