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.
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 failureor
1. INSERT order succeeds
2. DB COMMIT
3. JVM crashes before Kafka send completesIn 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 (...)
COMMITA 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
KafkaKey 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-kafkaDomain 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: trueFor 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: deleteTrue 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=outboxThis 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.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
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.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
