Back to Articles

Spring Boot · Data

@Transactional Won't Save You

2026-10-228 min read
TransactionsPostgreSQL

@Transactional is one of the most trusted annotations in Spring, and one of the most misunderstood. It rolls back your database changes on failure. It does not, and cannot, roll back anything outside the database.

The problem

An order flow that saves an order row, charges a card, reserves inventory, and sends a confirmation email looks like a single unit of work. It is not one unit at the transaction level, it is one database transaction wrapping three network calls to systems that have no idea a rollback just happened.

The naive solution

OrderService.java
1@Transactional
2public Order createOrder(OrderRequest request) {
3 Order order = orderRepository.save(new Order(request));
4
5 paymentClient.charge(order.getTotal(), request.cardToken());
6 inventoryService.reserve(order.getItems());
7 notificationClient.sendOrderConfirmation(order);
8
9 return order;
10}

If inventoryService.reserve() throws after the payment already succeeded, the @Transactional rollback undoes the order row. It does not, and has no way to, refund the charge that already went through on the payment gateway. The customer is now charged for an order that does not exist in your database.

Why it breaks

  • External API calls, Kafka messages, emails sent, and files written are not participants in a JDBC transaction. A rollback reverts rows, not real-world side effects.
  • Calling external systems inside a transaction also holds a database connection and any row locks for however long that network call takes, which under load turns a slow payment gateway into database connection pool exhaustion.
  • Partial failure becomes silent: an order that half-succeeded (charged, not reserved) is indistinguishable from one that fully succeeded unless you built the bookkeeping to notice.

The production solution

The transactional outbox pattern separates "what must be atomic with the database write" from "what talks to the outside world." Instead of calling external systems inside the transaction, write an event row in the same transaction as the order:

outbox_events table
1CREATE TABLE outbox_events (
2 id UUID PRIMARY KEY,
3 event_type VARCHAR(100) NOT NULL,
4 payload JSONB NOT NULL,
5 created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
6 published_at TIMESTAMPTZ
7);
OrderService.java
1@Transactional
2public Order createOrder(OrderRequest request) {
3 Order order = orderRepository.save(new Order(request));
4
5 // Same transaction, same commit, same rollback as the order row.
6 outboxRepository.save(new OutboxEvent(
7 "ORDER_CREATED",
8 OrderCreatedPayload.from(order)
9 ));
10
11 return order;
12}

The order and the event are now genuinely atomic: both commit together or neither does, because both are plain rows in the same database transaction. A separate process reads unpublished events and performs the actual side effects, with its own retry logic that does not hold a database transaction open while it waits on a network call:

OutboxPublisher.java
1@Scheduled(fixedDelay = 500)
2public void publishPendingEvents() {
3 List<OutboxEvent> pending = outboxRepository.findUnpublished();
4
5 for (OutboxEvent event : pending) {
6 try {
7 switch (event.getType()) {
8 case "ORDER_CREATED" -> {
9 paymentClient.charge(event.payload());
10 inventoryService.reserve(event.payload());
11 notificationClient.sendOrderConfirmation(event.payload());
12 }
13 default -> log.warn("Unknown outbox event type: {}", event.getType());
14 }
15 outboxRepository.markPublished(event.getId());
16 } catch (Exception e) {
17 log.error("Failed to publish outbox event {}, will retry", event.getId(), e);
18 }
19 }
20}

Making it reusable

The outbox_events table and publisher loop are generic across any workflow that needs "commit this row, then reliably trigger these side effects": order creation, user signup emails, webhook delivery. Only the switch branch handling each event_type is domain-specific.

Testing it

Two distinct tests matter here: that a failed order save produces no outbox event (the atomicity you're relying on), and that a publisher failure (payment gateway down) leaves the event unpublished for retry rather than marking it published anyway. The second one is easy to get backwards under deadline pressure and is the one that actually causes silent data loss in production.

What I learned

The outbox pattern trades immediate consistency for eventual consistency: the confirmation email might go out 500ms after the order commits instead of in the same request. For almost every real-world workflow, that delay is invisible to the user and completely worth it in exchange for never having a payment succeed against an order that does not exist.