When the Database Commits but Kafka Fails: Handling Inconsistent Order Transactions

The article explains why a successful database transaction can leave an order incomplete when the accompanying Kafka message fails, examines the limitations of @Transactional, and demonstrates how Spring Modulith's Event Publication Registry and the Outbox pattern provide reliable, idempotent messaging solutions.

LuTiao Programming
LuTiao Programming
LuTiao Programming
When the Database Commits but Kafka Fails: Handling Inconsistent Order Transactions

In a typical Spring Boot order service, developers often write a @Transactional method that saves an Order entity, sends an order-created Kafka message via kafkaTemplate.send, and then commits the transaction. While the database commit succeeds, the Kafka send may fail, resulting in an order record without a corresponding event, which breaks downstream processes such as inventory deduction, coupon redemption, and point accrual.

Why @Transactional Does Not Cover Kafka

@Transactional guarantees atomicity only for the underlying MySQL transaction. The Kafka send is a separate operation that cannot be rolled back together with the database changes. Failure windows include:

1. INSERT order succeeds
2. kafkaTemplate.send() is invoked
3. DB COMMIT
4. Kafka broker returns failure

or

1. INSERT order succeeds
2. DB COMMIT
3. JVM crashes before Kafka send completes

In both cases the orders table contains the new row, but the order-created topic lacks the message, leaving downstream services unaware of the order.

Moving Kafka Send After Commit

One improvement is to listen for the transaction commit using

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)

and publish the event only after the DB commit. This eliminates the case where the message is sent before the transaction succeeds, but it still leaves the problem of handling a failed Kafka send after the commit, because the DB cannot be rolled back.

Outbox Pattern with Spring Modulith

The classic Outbox solution writes both the order record and an outbox_event record inside the same DB transaction:

BEGIN
INSERT INTO orders (...)
INSERT INTO outbox_event (...)
COMMIT

A separate scheduled task scans outbox_event and sends each entry to Kafka, marking the row as SENT on success. This guarantees that either both the order and the event are persisted, or neither is.

Spring Modulith Event Publication Registry

Spring Modulith 2.1 provides a built‑in Event Publication Registry that automatically creates an EVENT_PUBLICATION table and records each published event together with the surrounding transaction. The table stores fields such as ID, EVENT_TYPE, STATUS, COMPLETION_ATTEMPTS, PUBLICATION_DATE, and COMPLETION_DATE. The registry defines statuses PUBLISHED, PROCESSING, COMPLETED, FAILED, and RESUBMITTED, allowing the application to retry failed publications without losing information.

Demo Setup

Environment:

Java 21
Spring Boot 4.1.1
Spring Modulith 2.1.1
MySQL 8
Kafka

Key Maven dependencies:

org.springframework.modulith:spring-modulith-bom:2.1.1 (import)
org.springframework.boot:spring-boot-starter-web
org.springframework.boot:spring-boot-starter-data-jpa
com.mysql:mysql-connector-j
org.springframework.boot:spring-boot-starter-kafka
org.springframework.modulith:spring-modulith-starter-jdbc
org.springframework.modulith:spring-modulith-events-kafka

Domain Model

Entity definition:

@Entity
@Table(name = "orders")
public class Order {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private Long userId;
    private BigDecimal amount;
    private String status;
    // constructors, getters
}

Repository:

public interface OrderRepository extends JpaRepository<Order> {}

Externalized Event

The event is annotated with @Externalized("order-created::#{#this.orderId()}"), which tells Spring Modulith to write the event to the publication table and later publish it to the order-created Kafka topic using the order ID as the message key.

import org.springframework.modulith.events.Externalized;

@Externalized("order-created::#{#this.orderId()}")
public record OrderCreatedEvent(Long orderId, Long userId, BigDecimal amount, Instant occurredAt) {}

Service Layer

The service now only saves the order and publishes the event; it no longer calls kafkaTemplate.send directly.

@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final ApplicationEventPublisher events;

    @Transactional
    public Long createOrder(CreateOrderCommand command) {
        Order order = new Order(command.userId(), command.amount());
        orderRepository.save(order);
        events.publishEvent(new OrderCreatedEvent(order.getId(), order.getUserId(), order.getAmount(), Instant.now()));
        return order.getId();
    }
}

Consumer Idempotency

Consumers should store a unique eventId (e.g., a UUID) and check it before processing to guarantee idempotent handling.

@KafkaListener(topics = "order-created")
@Transactional
public void consume(OrderCreatedEvent event) {
    if (processedEventRepository.existsById(event.eventId())) return;
    inventoryService.process(event.orderId());
    processedEventRepository.save(new ProcessedEvent(event.eventId()));
}

Retrying Failed Publications

Spring Modulith can automatically republish outstanding events on application restart:

spring:
  modulith:
    events:
      republish-outstanding-events-on-restart: true

For finer control, the FailedEventPublications API allows scheduled resubmission with configurable batch size, minimum failure age, concurrency limits, and retry counts.

@Component
public class FailedEventRetryJob {
    private final FailedEventPublications publications;
    @Scheduled(fixedDelay = 30_000)
    public void retry() {
        publications.resubmit(ResubmissionOptions.defaults());
    }
}

Publication Table Lifecycle

Completed rows remain in the table by default (UPDATE mode). For high‑volume systems you may switch to DELETE or ARCHIVE to keep the table size manageable:

spring:
  modulith:
    events:
      completion-mode: delete

True Outbox Support in Spring Modulith 2.1

Version 2.1 adds a genuine Outbox externalization mode that can be backed by Namastack or JobRunr. Enabling it is as simple as:

spring.modulith.events.externalization.mode=outbox

This mode provides advanced features such as multi‑instance coordination, ordered delivery, and robust failure handling, making it suitable for large‑scale, high‑reliability systems.

Takeaways

The core lesson is that database transaction commit alone does not guarantee downstream message delivery. Using Spring Modulith's Event Publication Registry—or the full Outbox mode—captures the intent to send a message within the same transaction, persists the intent, and offers reliable retry and idempotency mechanisms, turning a fragile “send‑and‑forget” approach into a robust, at‑least‑once delivery model.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

KafkaSpring BootMySQLOutboxSpring ModulithTransactional Events
LuTiao Programming
Written by

LuTiao Programming

LuTiao Programming is a friendly community offering free programming lessons. We inspire learners to explore new ideas and technologies and quickly acquire job-ready skills.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.