Spring Boot cookbook · Advanced
Publish events reliably with the transactional outbox
Save an order and publish an OrderCreated message to Kafka/RabbitMQ without losing or duplicating events on failures.
Approach
Write the event to an outbox table in the same transaction as the business change. A scheduled relay (or CDC with Debezium) reads unsent rows with FOR UPDATE SKIP LOCKED, publishes them, and marks them sent. Consumers deduplicate by event ID because delivery is at-least-once.
Solution
@Service
class OrderService {
@Transactional
public Order create(CreateOrder cmd) {
Order order = orders.save(Order.from(cmd));
outbox.save(OutboxEvent.of("order.created", order.getId().toString(),
json.writeValueAsString(new OrderCreated(order.getId(), order.getTotalCents()))));
return order;
}
}
@Component
class OutboxRelay {
private final JdbcTemplate jdbc;
private final KafkaTemplate<String, String> kafka;
OutboxRelay(JdbcTemplate jdbc, KafkaTemplate<String, String> kafka) { this.jdbc = jdbc; this.kafka = kafka; }
@Scheduled(fixedDelay = 500)
@Transactional
public void relay() {
List<Map<String, Object>> rows = jdbc.queryForList("""
SELECT id, type, aggregate_id, payload FROM outbox
WHERE sent_at IS NULL ORDER BY id LIMIT 100 FOR UPDATE SKIP LOCKED""");
for (var row : rows) {
kafka.send(row.get("type").toString(), row.get("aggregate_id").toString(), row.get("payload").toString()).join();
jdbc.update("UPDATE outbox SET sent_at = now() WHERE id = ?", row.get("id"));
}
}
}
The relay may publish a message and crash before marking it sent, so the message can be sent twice — consumers must be idempotent (store processed event IDs).
Pitfalls
- Publishing to the broker inside the business transaction (dual write).
- Outbox table growing forever — delete or archive sent rows.
- Ordering assumptions across aggregates (key by aggregate ID).
Variations
- Debezium outbox event router (CDC) instead of polling.
- Spring Modulith's externalized events with an event publication registry.
Further reading: microservices.io: Transactional outbox
Reference: https://microservices.io/patterns/data/transactional-outbox.html
More Spring Boot recipes
- Add correlation IDs to Spring Boot logs
- Add retries and a circuit breaker with Resilience4j
- Authenticate machine clients with API keys in Spring Security
- Cache service results in Redis with @Cacheable
- Consume Kafka messages with retries and a dead-letter topic
- Handle concurrent updates with optimistic locking
- Implement soft delete with Hibernate
- Make POST endpoints idempotent in Spring Boot