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