Back to Blog
spring-bootkafkaevent-drivenpatternsjavamicroservices

The Transactional Outbox Pattern: Guarantee Message Delivery with Spring Boot & Kafka (2026)

Saving to DB and publishing to Kafka in the same transaction seems simple. It's not — either can succeed while the other fails. The outbox pattern fixes this with guaranteed delivery.

J

JOptimize Team

May 28, 2026· 9 min read

The classic microservice reliability problem: you save an order to PostgreSQL and publish an OrderCreated event to Kafka. What happens if PostgreSQL succeeds but Kafka publish fails? The order exists in the DB but the payment service never processes it. What if Kafka succeeds but PostgreSQL rolls back? You charged the customer for an order that doesn't exist.

The transactional outbox pattern solves this without distributed transactions.


The Problem: Two-Phase Commit Is Dead

// UNRELIABLE — two separate systems, no atomicity @Transactional public Order createOrder(CreateOrderRequest req) { Order order = orderRepository.save(new Order(req)); // DB commit kafkaTemplate.send("orders", new OrderCreated(order)); // Kafka publish return order; // If Kafka fails after DB commit → event lost // If DB rolls back after Kafka send → ghost event }

The Solution: Outbox Table

Instead of publishing directly to Kafka, write the event to an outbox table in the same transaction as the business data. A separate relay process reads the outbox and publishes to Kafka.

Application Transaction:
  INSERT INTO orders (...)         ← Business data
  INSERT INTO outbox (event_data)  ← Event (same transaction)
  COMMIT

Outbox Relay (separate process):
  SELECT * FROM outbox WHERE published = false
  → kafkaTemplate.send(...)
  → UPDATE outbox SET published = true

Atomicity is guaranteed by the database transaction. Even if Kafka is down, the event is safely stored and will be published when Kafka recovers.


Implementation

-- Flyway migration CREATE TABLE outbox ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), aggregate_id VARCHAR(100) NOT NULL, event_type VARCHAR(100) NOT NULL, payload JSONB NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT NOW(), published BOOLEAN NOT NULL DEFAULT FALSE, published_at TIMESTAMP ); CREATE INDEX idx_outbox_unpublished ON outbox(published, created_at) WHERE published = FALSE;
@Entity @Table(name = "outbox") public class OutboxEvent { @Id @GeneratedValue(strategy = GenerationType.UUID) private UUID id; private String aggregateId; private String eventType; @Column(columnDefinition = "jsonb") private String payload; private LocalDateTime createdAt; private boolean published; private LocalDateTime publishedAt; }
@Service @RequiredArgsConstructor public class OrderService { private final OrderRepository orderRepository; private final OutboxRepository outboxRepository; private final ObjectMapper objectMapper; @Transactional // Both DB writes in ONE transaction public Order createOrder(CreateOrderRequest req) { Order order = orderRepository.save(new Order(req)); // Write event to outbox — same transaction OutboxEvent outboxEvent = new OutboxEvent(); outboxEvent.setAggregateId(order.getId().toString()); outboxEvent.setEventType("OrderCreated"); outboxEvent.setPayload(objectMapper.writeValueAsString( new OrderCreatedEvent(order.getId(), order.getTotal(), order.getCustomerId()) )); outboxRepository.save(outboxEvent); return order; } }

Outbox Relay: Polling-Based

@Component @RequiredArgsConstructor public class OutboxRelay { private final OutboxRepository outboxRepository; private final KafkaTemplate<String, String> kafkaTemplate; @Scheduled(fixedDelay = 1000) // Poll every second @Transactional public void relay() { List<OutboxEvent> pending = outboxRepository .findTop100ByPublishedFalseOrderByCreatedAtAsc(); pending.forEach(event -> { try { kafkaTemplate.send( topicFor(event.getEventType()), event.getAggregateId(), event.getPayload() ).get(5, TimeUnit.SECONDS); // Wait for ack event.setPublished(true); event.setPublishedAt(LocalDateTime.now()); outboxRepository.save(event); } catch (Exception e) { log.error("Failed to publish event {}: {}", event.getId(), e.getMessage()); // Will retry on next poll } }); } private String topicFor(String eventType) { return switch (eventType) { case "OrderCreated" -> "orders.created"; case "OrderShipped" -> "orders.shipped"; case "OrderCancelled" -> "orders.cancelled"; default -> throw new IllegalArgumentException("Unknown event type: " + eventType); }; } }

Outbox Relay: Debezium CDC (Production-Grade)

For production, use Debezium Change Data Capture instead of polling:

# Debezium connector config name: outbox-connector config: connector.class: io.debezium.connector.postgresql.PostgresConnector database.hostname: postgres database.port: 5432 database.dbname: myapp table.include.list: public.outbox transforms: outbox transforms.outbox.type: io.debezium.transforms.outbox.EventRouter transforms.outbox.table.field.event.key: aggregate_id transforms.outbox.route.by.field: event_type

Debezium reads PostgreSQL's WAL (write-ahead log) and publishes to Kafka — no polling overhead, sub-second latency, zero missed events.


Cleaning Up the Outbox Table

@Scheduled(cron = "0 0 3 * * *") // 3 AM daily @Transactional public void cleanOldEvents() { LocalDateTime threshold = LocalDateTime.now().minusDays(7); int deleted = outboxRepository .deleteByPublishedTrueAndPublishedAtBefore(threshold); log.info("Cleaned {} published outbox events", deleted); }

Common Mistakes to Avoid

  • Querying the entire outbox without a WHERE clause — always filter WHERE published = FALSE with an index; scanning all events is O(n)
  • Not idempotent Kafka consumer — the relay may publish the same event twice on crash/restart; your consumers must handle duplicates (idempotency key in the event)
  • Publishing from @TransactionalEventListener — Spring's AFTER_COMMIT event listener is still outside the transaction; a Kafka failure here loses the event; use the outbox table instead
  • No cleanup job — the outbox table grows forever without cleanup; old published events are waste

Summary

The transactional outbox pattern guarantees reliable event publishing by writing events to a database table in the same transaction as business data, then relaying them to Kafka separately. The database provides atomicity — either both the business data and the event are saved, or neither is. For polling-based relay, a @Scheduled method works fine. For production, Debezium CDC provides sub-second latency with zero polling overhead.


Detect Unreliable Event Publishing

JOptimize flags direct Kafka send() calls inside @Transactional methods without the outbox pattern, missing idempotency handling in Kafka consumers, and @TransactionalEventListener misuse.

Never lose another event in production — free reliability scan.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.