Implementing the Outbox Pattern with Kafka and Spring Boot

The Outbox Pattern is a crucial architectural solution for ensuring reliable event publication in distributed systems. It enables decoupling between transactional data changes and event-driven systems like Kafka, ensuring consistency without requiring distributed transactions.
This article demonstrates how to implement the Outbox Pattern using:
- Java 21
- Spring Boot 3.x
- Apache Kafka
- PostgreSQL (as the database)
- Maven
- JUnit 5
💡 What Is the Outbox Pattern?
Instead of publishing an event directly to Kafka within the same transaction where the database is modified, the event is written to a local "outbox" table in the same transaction. A background process (poller or Debezium CDC) then reads the outbox table and publishes the event to Kafka.
📦 Project Setup
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0" ...>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>outbox-kafka</artifactId>
<version>1.0.0</version>
<properties>
<java.version>21</java.version>
<spring.boot.version>3.2.0</spring.boot.version>
</properties>
<dependencies>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- Kafka -->
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<!-- PostgreSQL -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
🗃️ Database Entities
Outbox Entity
@Entity
@Table(name = "outbox")
public class OutboxEvent {
@Id
private UUID id;
private String aggregateType;
private String aggregateId;
private String eventType;
@Lob
private String payload;
private Instant createdAt;
}
Business Entity
@Entity
public class Order {
@Id
@GeneratedValue
private Long id;
private String status;
}
⚙️ Service and Outbox Write
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final OutboxRepository outboxRepository;
public OrderService(OrderRepository orderRepository, OutboxRepository outboxRepository) {
this.orderRepository = orderRepository;
this.outboxRepository = outboxRepository;
}
@Transactional
public Order createOrder() {
Order order = new Order();
order.setStatus("CREATED");
orderRepository.save(order);
OutboxEvent event = new OutboxEvent();
event.setId(UUID.randomUUID());
event.setAggregateType("Order");
event.setAggregateId(order.getId().toString());
event.setEventType("OrderCreated");
event.setPayload("{"orderId": " + order.getId() + "}");
event.setCreatedAt(Instant.now());
outboxRepository.save(event);
return order;
}
}
🌀 Polling Publisher
@Component
public class OutboxPublisher {
private final OutboxRepository outboxRepository;
private final KafkaTemplate<String, String> kafkaTemplate;
@Scheduled(fixedRate = 5000)
public void publishOutboxEvents() {
List<OutboxEvent> events = outboxRepository.findTop10ByOrderByCreatedAtAsc();
for (OutboxEvent event : events) {
kafkaTemplate.send("order-events", event.getAggregateId(), event.getPayload());
outboxRepository.delete(event); // Delete after publishing
}
}
}
🧪 Unit and Integration Tests
OrderServiceTest.java
@ExtendWith(SpringExtension.class)
@SpringBootTest
class OrderServiceTest {
@Autowired
private OrderService orderService;
@Autowired
private OutboxRepository outboxRepository;
@Test
void testOrderCreationWritesOutboxEvent() {
Order order = orderService.createOrder();
List<OutboxEvent> events = outboxRepository.findByAggregateId(order.getId().toString());
assertFalse(events.isEmpty());
assertEquals("OrderCreated", events.get(0).getEventType());
}
}
OutboxPublisherTest.java
@SpringBootTest
class OutboxPublisherTest {
@Autowired
private KafkaTemplate<String, String> kafkaTemplate;
@Autowired
private OutboxPublisher outboxPublisher;
@Autowired
private OutboxRepository outboxRepository;
@Test
void testPublishingToKafka() {
OutboxEvent event = new OutboxEvent();
event.setId(UUID.randomUUID());
event.setAggregateType("Order");
event.setAggregateId("123");
event.setEventType("OrderCreated");
event.setPayload("{"orderId": 123}");
event.setCreatedAt(Instant.now());
outboxRepository.save(event);
outboxPublisher.publishOutboxEvents();
List<OutboxEvent> remaining = outboxRepository.findByAggregateId("123");
assertTrue(remaining.isEmpty());
}
}
🧵 Conclusion
The Outbox Pattern is an excellent solution when you want to guarantee atomicity between database operations and message publishing in a microservices environment.
By combining Spring Boot, Kafka, and a relational database, we’ve implemented a resilient and production-grade foundation for eventual consistency.
📚 Further Reading
Subscribe to my newsletter
Read articles from Vitali R directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by