Intermediate · Decouple
Publish Reliably with Kafka & the Outbox Pattern
Close the database-plus-broker dual-write gap and consume at-least-once messages safely.
Primary source: Spring Boot 3.5 Reference: Messaging with Apache Kafka
Lesson 2 of 4
1d streakThe Architectural Question
Writing the database row and publishing the event touch two systems; without an outbox, crashes between the two lose events or emit ghosts for rolled-back transactions.
What this guide proves
- Commit business state and outbox record atomically
- Make consumer effects idempotent
- Choose partition keys from required ordering guarantees
Apache Kafka Producer and Consumer Architecture
Apache Kafka provides distributed, partitioned commit-log semantics for high-throughput event streaming. Spring for Apache Kafka (spring-kafka) wraps native Kafka clients with KafkaTemplate for producing and @KafkaListener for consuming.
Key Kafka architectural guarantees:
1. Partition Ordering: Messages with the same key (e.g. orderId) are guaranteed to arrive on the same partition in strict sequential order.
2. Consumer Groups: Multiple instances of a service sharing the same group.id share partition assignments, providing horizontal load balancing and fault tolerance.
package com.example.kafka;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.support.KafkaHeaders;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.stereotype.Service;
@Service
public class OrderEventService {
private final KafkaTemplate<String, OrderEvent> kafkaTemplate;
public OrderEventService(KafkaTemplate<String, OrderEvent> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public void publishOrderPlaced(OrderEvent event) {
// Publish using orderId as key to ensure partition affinity
kafkaTemplate.send("orders.placed.v1", event.orderId(), event);
}
@KafkaListener(topics = "orders.placed.v1", groupId = "inventory-group")
public void handleOrderPlaced(OrderEvent event, @Header(KafkaHeaders.RECEIVED_PARTITION) int partition) {
// Process inventory allocation
}
}The Transactional Outbox Pattern with PostgreSQL & Kafka
Directly calling kafkaTemplate.send() inside a Spring @Transactional database method is an architectural anti-pattern known as Dual-Write. If the database transaction commits but the Kafka broker network connection times out, the event is permanently lost, leading to distributed state divergence.
The Transactional Outbox pattern guarantees zero-loss event publishing: write the event into an outbox table in PostgreSQL inside the same local relational transaction, then publish asynchronously to Kafka using Change Data Capture (Debezium) or a polling worker.
CREATE TABLE outbox_events (
id UUID PRIMARY KEY,
aggregate_type VARCHAR(64) NOT NULL,
aggregate_id VARCHAR(128) NOT NULL,
event_type VARCHAR(128) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
processed BOOLEAN DEFAULT FALSE NOT NULL
);
CREATE INDEX idx_outbox_unprocessed ON outbox_events(created_at) WHERE processed = FALSE;Consumer Idempotency and Deduplication in Distributed Systems
Kafka provides at-least-once delivery semantics by default. Network retries, rebalances, and consumer crashes mean that your consumer will receive duplicate messages. Consumers must be designed to be completely idempotent.
package com.example.kafka;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class IdempotentPaymentConsumer {
private final JdbcClient jdbcClient;
public IdempotentPaymentConsumer(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Transactional
public boolean processPaymentOnce(String eventId, String customerId, int amountCents) {
try {
// Atomic deduplication using UNIQUE constraint on processed_events(event_id)
jdbcClient.sql("INSERT INTO processed_events (event_id, processed_at) VALUES (?, NOW())")
.param(eventId)
.update();
} catch (DataIntegrityViolationException ex) {
// Already processed in earlier delivery; return without duplicating payment
return false;
}
// Execute payment deduction exactly once
return true;
}
}Verifying Outbox Atomicity and Duplicate Defense
Verify transactional outbox persistence and consumer deduplication using Testcontainers Kafka:
# 1. Trigger order creation with outbox write
curl -X POST http://localhost:8080/api/orders -d '{"orderId":"ORD-42","amount":1999}'
# 2. Verify PostgreSQL outbox table contains pending record
psql -d app_db -c "SELECT aggregate_id, event_type, processed FROM outbox_events WHERE aggregate_id='ORD-42';"
# aggregate_id | event_type | processed
# -------------+--------------+----------
# ORD-42 | ORDER_PLACED | f
# 3. Simulate Kafka rebalance and replay of message
# Verify processed_events table prevents duplicate balance deductionsActive Recall Checkpoint
Why does calling kafkaTemplate.send() inside @Transactional fail to guarantee event delivery?
Hint: Think about the two-phase commit fallacy in distributed systems.
Show answer & explanation
If the database commits but the Kafka producer network request fails or times out, the database changes exist without the event ever reaching Kafka.
Runtime Architecture & Flow
High-value visual model
Outbox Crash Points
The outbox pattern separates local database consistency from broker delivery while the consumer handles duplicate messages deliberately.
1. Commit state and outbox
Business row and outbox event are written in one database transaction.
2. Relay publishes
A background relay publishes committed events to Kafka.
3. Broker may redeliver
Retries and rebalances can repeat a message.
4. Consumer deduplicates
The business effect and processed-event marker commit together.
Working Implementation
Code Walkthrough
Publish order-created through an outbox relay and deduplicate by event ID.
package com.example.kafka;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.UUID;
@Service
public class KafkaOutboxLab {
private final JdbcClient jdbcClient;
private final KafkaTemplate<String, String> kafkaTemplate;
public KafkaOutboxLab(JdbcClient jdbcClient, KafkaTemplate<String, String> kafkaTemplate) {
this.jdbcClient = jdbcClient;
this.kafkaTemplate = kafkaTemplate;
}
// Transactional Outbox pattern: local DB write ensures zero lost messages
@Transactional
public void saveOrderAndOutbox(String orderId, String payload) {
jdbcClient.sql("INSERT INTO orders (id, status) VALUES (?, 'PLACED')")
.param(orderId)
.update();
jdbcClient.sql("""
INSERT INTO outbox_events (id, aggregate_type, aggregate_id, event_type, payload, created_at)
VALUES (?, 'ORDER', ?, 'ORDER_CREATED', ?, ?)
""")
.params(UUID.randomUUID(), orderId, payload, Instant.now())
.update();
}
// Consumer idempotency: duplicate deliveries return early without duplicating state changes
@Transactional
public boolean consumeOrderEventIdempotently(String eventId) {
try {
jdbcClient.sql("INSERT INTO processed_events (event_id, processed_at) VALUES (?, ?)")
.params(eventId, Instant.now())
.update();
return true;
} catch (DataIntegrityViolationException ex) {
// Already processed; deduplicated successfully
return false;
}
}
}Synthesis & Production Verification
Deliberate Failure Condition
- Break: Call kafkaTemplate.send() inside the order transaction
- Observe: The broker receives events for transactions that later roll back, or none for ones that commit
- Repair: Commit business state and an outbox record atomically, relay afterward, and make consumer effects idempotent by event ID.
Production Acceptance Criteria
- Persist order state plus outbox row in one transaction
- Relay published records to Kafka and track delivery progress
- Replay duplicate deliveries without duplicating fulfillment
Key Takeaway
The Transactional Outbox pattern eliminates the database-broker dual-write dilemma, while consumer deduplication using unique event IDs ensures at-least-once safety.
Architectural & Technical Review
Core Interview Questions
Q01What is the distributed dual-write problem when publishing to Kafka inside a Spring @Transactional method?Show answer
If the database transaction commits but the network request to Kafka times out or fails, the event is permanently lost. Conversely, if the message sends but the DB rolls back, an invalid event is published. Writing to a local outbox table within the same transaction ensures atomicity.
Q02Why does Kafka guarantee at-least-once delivery, and how do consumers achieve idempotency?Show answer
Consumer rebalances, network retries, and commit timeouts cause Kafka to deliver duplicate messages. Consumers achieve idempotency by inserting the unique message/event ID into a processed_events table with a PRIMARY KEY / UNIQUE constraint within the consumer transaction.
Q03How does Kafka preserve message ordering across consumer instances?Show answer
Kafka guarantees strict sequential ordering strictly within a single partition. Publishing messages with the aggregate ID (e.g. orderId) as the message key hashes all events for that entity to the exact same partition.
Production Pitfalls
Critical Anti-Patterns
- Pitfall: Publishing directly to Kafka via KafkaTemplate inside a database transaction without an outbox — Broker connection drops after database commit cause permanent distributed state desynchronization.
- Pitfall: Assuming Kafka auto-commit prevents duplicate message processing during container restarts — Auto-commit commits offsets asynchronously; pod crashes before commit cause all messages since the last commit to be re-processed.
Primary Documentation & Specifications
Lesson Primary References
Supplementary Module Resources