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.
JOptimize Team
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.
// 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 }
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.
-- 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; } }
@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); }; } }
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.
@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); }
WHERE published = FALSE with an index; scanning all events is O(n)@TransactionalEventListener — Spring's AFTER_COMMIT event listener is still outside the transaction; a Kafka failure here loses the event; use the outbox table insteadThe 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.
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.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.