Transactional Outbox Pattern in Java and Spring Boot with Kafka

Transactional Outbox Pattern in Java and Spring Boot with Kafka

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:

  1. Save the order in the MySQL database.
  2. Publish an OrderCreated event 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.

Transactional Outbox Pattern Architecture The Order Service saves the order and the outbox event in one MySQL transaction. A separate Outbox Publisher reads pending events and sends them to Kafka, which delivers them to the Notification Service. Order Service Create customer order ONE MYSQL TRANSACTION orders Business data Order saved outbox_events Outgoing event Pending publication Both commit or both roll back Outbox Publisher Reads pending events Apache Kafka Notification Service
Figure: The order and the outbox event commit in one transaction. A separate publisher delivers the event to Kafka.

The process is simple:

  1. The customer places an order.
  2. Order Service creates the order.
  3. The application saves the order in the orders table.
  4. The application saves an OrderCreated event in the outbox_events table.
  5. Both database records are committed in one transaction.
  6. An independent publisher reads pending events.
  7. The publisher sends the event to Kafka.
  8. 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:

  1. The publisher sends an event to Kafka.
  2. Kafka acknowledges the message.
  3. 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

  1. Write the outbox record in the same transaction as the business change. Do not publish to the broker inside the business transaction.
  2. Use a stable, unique event ID. This helps with deduplication and tracing.
  3. Make consumers idempotent. Assume duplicate delivery is possible.
  4. Index the outbox table for pending lookups. A composite index on published and createdAt is a common starting point.
  5. Clean up published records. Keep retention policies so the outbox table does not grow without bound.
  6. Add a failure strategy. Decide how to handle poison messages, repeated failures, and alerts.
  7. Monitor publication lag. Track how long events wait in the outbox before they are published.
  8. Use partitioning when ordering matters. Choose a partition key that matches the ordering requirement for related events.
  9. 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.
  10. 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 published and createdAt is 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: