Transactional Outbox Pattern in Java and Spring Boot: A Complete Real-World Example
What happens when your database saves an order successfully, but Kafka fails to receive the order event?
This is a real problem in microservices applications. Your customer sees an order confirmation, but your Notification Service never receives the message. The order exists in MySQL, but the email or notification is missing.
Many developers initially solve this by saving data in the database and publishing a Kafka message immediately afterward. It looks simple, but it can create a serious production issue called the Dual-Write Problem.
In this tutorial, we will understand the problem using a simple online shopping example and build a complete learning project using:
- Java 17
- Spring Boot 3
- Spring Data JPA
- MySQL
- Apache Kafka
- Docker Compose
- Maven
We will also understand how the Outbox Pattern works, why duplicate messages can occur, how polling works, when to use Debezium CDC, and what interviewers expect you to know.
1. What Is the Transactional Outbox Pattern?
The Transactional Outbox Pattern is a distributed systems and messaging integration pattern. It helps a microservice reliably publish events after changing its own database.
Instead of directly publishing an event to Kafka as part of the
business operation, the application saves that event in a separate
database table called outbox_events.
The business record and the outgoing event are saved in the same local database transaction.
A separate publisher reads pending events from the outbox table and sends them to Kafka or another message broker.
Which design pattern category does it belong to?
- Architectural Pattern
- Distributed Systems Pattern
- Messaging and Integration Pattern
It is not a traditional Gang of Four (GoF) Creational, Structural, or Behavioral pattern.
Key participants
- Business service: Performs the business operation and writes the outbox record.
- Outbox table: Stores outgoing events within the same database.
- Publisher: Reads pending events and sends them to the broker.
- Message broker: Delivers events to downstream consumers, such as Kafka.
- Consumer: Processes the event, often with idempotent handling.
2. Understand the Problem With a Simple Shopping Example
Imagine that you are building an online shopping application.
A customer places an order for a mobile phone. Your Order Service must perform two operations:
- Save the order in the MySQL database.
-
Publish an
OrderCreatedevent to Kafka so the Notification Service can send an order confirmation.
Here is what a normal implementation might look like:
@Transactional
public void createOrder(Order order) {
orderRepository.save(order);
kafkaTemplate.send(
"order-events",
"OrderCreated"
);
}
At first glance, everything looks correct. But the database and Kafka are separate systems. A normal MySQL transaction does not automatically make a Kafka publish part of the same atomic transaction.
Scenario A: MySQL succeeds, but Kafka fails
- The order is saved successfully.
- Kafka is temporarily unavailable.
- The event is not published.
- The customer has an order, but the Notification Service does not know about it.
Scenario B: Kafka succeeds, but the database transaction fails
- Kafka receives the event.
- The database transaction rolls back.
- The Notification Service receives an event for an order that does not exist.
Both situations can create inconsistent data between microservices.
This is called the Dual-Write Problem: an application needs to update two independent systems, but the two operations are not automatically one atomic transaction.
3. How Does the Outbox Pattern Solve This Problem?
Instead of sending a message directly to Kafka, we first save the message in the database.
Let's look at the complete flow.
The process is simple:
- The customer places an order.
- Order Service creates the order.
- The application saves the order in the
orderstable. - The application saves an
OrderCreatedevent in theoutbox_eventstable. - Both database records are committed in one transaction.
- An independent publisher reads pending events.
- The publisher sends the event to Kafka.
- Notification Service consumes the event and processes the notification.
Important: If the application cannot save the outbox event, the order transaction also rolls back. The event is not lost merely because Kafka is temporarily unavailable.
4. Complete Project: Spring Boot + MySQL + Kafka
Now let's build a small but complete application. We will use an online shopping order as our real-world example.
Project flow
POST /api/orders → Save Order → Save Outbox Event →
Commit Transaction → Publisher → Kafka → Consumer.
Project structure
transactional-outbox-demo
│
├── src/main/java/com/example/outbox
│ ├── OutboxApplication.java
│ │
│ ├── controller
│ │ └── OrderController.java
│ │
│ ├── entity
│ │ ├── PurchaseOrder.java
│ │ └── OutboxEvent.java
│ │
│ ├── repository
│ │ ├── OrderRepository.java
│ │ └── OutboxRepository.java
│ │
│ ├── service
│ │ └── OrderService.java
│ │
│ └── messaging
│ ├── OutboxPublisher.java
│ └── OrderEventConsumer.java
│
├── src/main/resources
│ └── application.properties
│
├── docker-compose.yml
└── pom.xml
5. Step 1: Create the Maven Project
Create a Spring Boot project with Java 17 and add the following dependencies.
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.5</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>transactional-outbox-demo</artifactId>
<version>1.0.0</version>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
This example uses Spring Boot 3.5.5 as its project version. You can use a compatible newer Spring Boot 3.x version if required.
6. Step 2: Start MySQL and Kafka Using Docker
You do not need to install MySQL and Kafka separately for this
learning project. Create a file named docker-compose.yml
in the project root.
docker-compose.yml
services:
mysql:
image: mysql:8.4
container_name: outbox-mysql
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: outbox_demo
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
kafka:
image: apache/kafka:3.9.1
container_name: outbox-kafka
ports:
- "9092:9092"
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@localhost:9093
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
volumes:
mysql_data:
Start the containers:
docker compose up -d
Check whether both containers are running:
docker compose ps
These settings are for a local single-broker demonstration, not a production Kafka cluster.
7. Step 3: Configure Spring Boot
Create application.properties.
spring.application.name=transactional-outbox-demo
spring.datasource.url=jdbc:mysql://localhost:3306/outbox_demo
spring.datasource.username=root
spring.datasource.password=root
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.kafka.bootstrap-servers=localhost:9092
spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer
spring.kafka.producer.value-serializer=org.apache.kafka.common.serialization.StringSerializer
spring.kafka.consumer.group-id=notification-service
spring.kafka.consumer.auto-offset-reset=earliest
spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer
spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.StringDeserializer
spring.kafka.listener.ack-mode=record
For this example, Hibernate creates the tables automatically. For production, use a database migration tool such as Flyway or Liquibase and manage credentials outside source control.
8. Step 4: Create the Order Entity
We will use a simple order with a product name, quantity, and total amount.
PurchaseOrder.java
package com.example.outbox.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "orders")
public class PurchaseOrder {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String productName;
private Integer quantity;
private BigDecimal totalAmount;
private String status;
private LocalDateTime createdAt;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getProductName() {
return productName;
}
public void setProductName(String productName) {
this.productName = productName;
}
public Integer getQuantity() {
return quantity;
}
public void setQuantity(Integer quantity) {
this.quantity = quantity;
}
public BigDecimal getTotalAmount() {
return totalAmount;
}
public void setTotalAmount(BigDecimal totalAmount) {
this.totalAmount = totalAmount;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
}
9. Step 5: Create the Outbox Event Entity
This table stores outgoing events before they are published to Kafka.
OutboxEvent.java
package com.example.outbox.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(
name = "outbox_events",
indexes = {
@Index(
name = "idx_outbox_pending",
columnList = "published,createdAt"
)
}
)
public class OutboxEvent {
@Id
private String id;
private String aggregateId;
private String eventType;
@Column(columnDefinition = "TEXT")
private String payload;
private boolean published;
private LocalDateTime createdAt;
private LocalDateTime publishedAt;
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getAggregateId() {
return aggregateId;
}
public void setAggregateId(String aggregateId) {
this.aggregateId = aggregateId;
}
public String getEventType() {
return eventType;
}
public void setEventType(String eventType) {
this.eventType = eventType;
}
public String getPayload() {
return payload;
}
public void setPayload(String payload) {
this.payload = payload;
}
public boolean isPublished() {
return published;
}
public void setPublished(boolean published) {
this.published = published;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
public LocalDateTime getPublishedAt() {
return publishedAt;
}
public void setPublishedAt(LocalDateTime publishedAt) {
this.publishedAt = publishedAt;
}
}
Notice that we use a unique event ID. This ID will also help us detect duplicate messages later.
10. Step 6: Create Repository Interfaces
OrderRepository.java
package com.example.outbox.repository;
import com.example.outbox.entity.PurchaseOrder;
import org.springframework.data.jpa.repository.JpaRepository;
public interface OrderRepository
extends JpaRepository<PurchaseOrder, Long> {
}
OutboxRepository.java
package com.example.outbox.repository;
import com.example.outbox.entity.OutboxEvent;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface OutboxRepository
extends JpaRepository<OutboxEvent, String> {
List<OutboxEvent>
findTop100ByPublishedFalseOrderByCreatedAtAsc();
}
The repository method reads up to 100 unpublished events, ordered by their creation time.
11. Why Duplicate Messages Can Occur
The Transactional Outbox Pattern commonly provides at-least-once delivery, not exactly-once delivery.
Consider a typical publishing step:
- The publisher sends an event to Kafka.
- Kafka acknowledges the message.
- The publisher marks the outbox row as published.
If the application crashes after Kafka acknowledges the message but before the outbox row is marked as published, the event will be sent again on the next polling cycle.
A crash is only one cause. A timeout after a successful publish, a network failure, or a retry after an ambiguous acknowledgement can also result in the same event being delivered more than once.
How to handle duplicates
- Idempotent consumers: Design the consumer so that processing the same event twice produces the same result.
- Deduplication by event ID: Store processed event IDs and ignore events that have already been handled.
- Unique constraints: Use database constraints to protect against duplicate state changes.
- Kafka idempotent producer: This reduces duplicate writes caused by producer retries, but it does not remove the need for consumer-side idempotency.
Do not assume the outbox pattern gives exactly-once processing. Design for at-least-once delivery and make consumers idempotent.
12. Polling vs Debezium CDC
There are two common ways to publish outbox events. Both have trade-offs.
| Approach | How it works | Trade-offs |
|---|---|---|
| Polling publisher | A scheduled job reads unpublished rows from the outbox table and sends them to the broker. | Simple to implement. Publication latency depends on the polling interval. Frequent polling adds database load, and multiple instances need coordination to avoid double publishing. |
| Debezium CDC | Debezium reads the database transaction log and streams outbox changes to Kafka. | Lower publication latency and no polling loop. Requires additional infrastructure, database-specific configuration, and operational knowledge. |
For a learning project, a polling publisher is a reasonable starting point. For high-throughput production systems, CDC is often preferred because it follows the committed transaction log directly.
13. Advantages and Disadvantages
Advantages
- Atomic business change and event record: Both are written in one local transaction.
- No lost events due to temporary broker outages: Events remain in the outbox until they are published.
- Clear audit trail: The outbox table records what was produced and when.
- Decoupled publishing: The business transaction does not wait for the broker.
- Retry-friendly: Failed publications can be retried from the outbox.
Disadvantages
- Additional database table: Outbox records must be written, read, and eventually cleaned up.
- Duplicate delivery: Consumers must be idempotent.
- Extra infrastructure or scheduling: Either a polling publisher or a CDC pipeline must be operated.
- Eventual consistency: Downstream systems see the event after the publisher delivers it, not immediately.
- Ordering considerations: Ordering guarantees depend on partitioning strategy and publisher behavior.
14. Best Practices
- Write the outbox record in the same transaction as the business change. Do not publish to the broker inside the business transaction.
- Use a stable, unique event ID. This helps with deduplication and tracing.
- Make consumers idempotent. Assume duplicate delivery is possible.
-
Index the outbox table for pending lookups. A
composite index on
publishedandcreatedAtis a common starting point. - Clean up published records. Keep retention policies so the outbox table does not grow without bound.
- Add a failure strategy. Decide how to handle poison messages, repeated failures, and alerts.
- Monitor publication lag. Track how long events wait in the outbox before they are published.
- Use partitioning when ordering matters. Choose a partition key that matches the ordering requirement for related events.
- Do not treat the outbox as a full event store. It is an internal mechanism for reliable publication, not a general audit log for every domain event forever.
- Use database migrations in production. Do not rely on automatic schema generation for real deployments.
15. Java and System Design Interview Questions and Answers
Q1. What is the Transactional Outbox Pattern?
It is a pattern that saves a business record and an outgoing event in the same database transaction, then publishes the event separately to a message broker.
Q2. What problem does it solve?
It solves the Dual-Write Problem, where the database and the message broker are separate systems and cannot participate in one atomic transaction.
Q3. Why not send directly to Kafka inside the business transaction?
A normal database transaction does not make a Kafka publish atomic. If Kafka fails or the database rolls back after publishing, the two systems can become inconsistent.
Q4. Does the outbox pattern guarantee exactly-once delivery?
No. It commonly provides at-least-once delivery. Consumers should be idempotent or deduplicate events using the event ID.
Q5. How do you prevent duplicate processing?
Store processed event IDs, use unique constraints, and design idempotent consumers so that reprocessing the same event produces the same result.
Q6. What is the difference between polling and CDC for the outbox?
Polling reads unpublished rows on a schedule. CDC reads the database transaction log and streams changes. Polling is simpler; CDC has lower latency and avoids polling load but requires more infrastructure.
Q7. Why is the outbox record written in the same transaction as the business record?
Because both are stored in the same database, one local transaction can atomically commit or roll back both of them. This removes the inconsistency caused by one operation succeeding while the other fails.
Q8. How should the outbox table be indexed?
A common starting point is a composite index on the published flag and creation time, so the publisher can efficiently find old unpublished events.
Q9. What happens if the publisher fails after sending to Kafka?
The event may be delivered again. This is why at-least-once delivery and idempotent consumers are part of the pattern's operational model.
Q10. How do you clean up the outbox table?
Published records can be archived or deleted according to a retention policy. The policy should reflect audit, debugging, and compliance requirements.
Q11. How does the outbox pattern relate to eventual consistency?
Downstream services see the event after it is published, not at the instant the business transaction commits. The system is eventually consistent, and consumers must handle that delay.
Q12. Does the outbox pattern replace distributed transactions?
It avoids the need for cross-system atomic transactions for this specific use case. It does not solve every distributed transaction problem, such as multi-service compensation.
Q13. Can you use the outbox pattern without Kafka?
Yes. The broker can be Kafka, RabbitMQ, another messaging system, or even an HTTP-based integration. The core idea is the local outbox table and a separate publisher.
Q14. What are common signs that an outbox implementation is unhealthy?
Growing unpublished rows, increasing publication lag, repeated poison messages, and missing outbox cleanup are common warning signs.
16. Frequently Asked Questions
Is the Transactional Outbox Pattern a Gang of Four pattern?
No. It is an architectural and distributed systems pattern, not a traditional GoF creational, structural, or behavioral pattern.
Is it only for Kafka?
No. Kafka is a common broker, but the pattern works with any messaging system. The essential idea is the same: commit business state and the outgoing event together in one local database transaction.
Do I need Debezium?
No. A polling publisher is a valid starting point. Debezium CDC is an option when you want lower latency and transaction-log-driven publication.
Is the outbox table a general event store?
No. It is an internal mechanism for reliable publication. Keep it focused on pending outbound events, and do not use it as a permanent audit log unless you have designed it for that purpose.
Can the outbox pattern be used with relational databases only?
It is most often used with relational databases because they provide strong local transactions. Some NoSQL databases have limited or different transaction support, so the implementation varies.
17. Quick Revision Checklist
- The outbox pattern solves the Dual-Write Problem.
- Business data and the outbox event are committed in one local transaction.
- A separate publisher reads pending events and sends them to the broker.
- The pattern typically provides at-least-once delivery, not exactly-once.
- Consumers should be idempotent or deduplicate events by event ID.
- A composite index on
publishedandcreatedAtis a common outbox index. - Polling is simpler; Debezium CDC offers lower latency and less polling overhead.
- Published outbox rows should be cleaned up according to a retention policy.
- Ordering depends on partitioning and publisher behavior.
- The outbox is an internal mechanism, not a general event store.
Conclusion
The Transactional Outbox Pattern is a practical way to reliably publish events when your application also needs to change its own database. By saving the business record and the outbox event in one local transaction, the application avoids the Dual-Write Problem and removes the risk of losing an event merely because the broker was temporarily unavailable.
The pattern does not eliminate every concern. Duplicate delivery is possible, so consumers must be idempotent. A publisher or CDC pipeline must be operated, and the outbox table must be indexed and cleaned up over time. These are reasonable costs for reliable event publication.
When you prepare for Java and system design interviews, focus on the "why" more than the "how". Understand why a Kafka publish cannot be part of a normal database transaction, why at-least-once delivery is the realistic model, and why idempotency is required. That reasoning is what separates a working example from a production-ready design.
No comments:
Post a Comment