The bug that doesn't show up in tests — and what to do about it
There is a class of bug in event-driven systems that is almost invisible in development and devastating in production: publishing a message to Kafka for data that never actually reached the database.
It doesn't crash. It doesn't throw. The Kafka message goes out, the consumer picks it up, and it tries to process a batch that doesn't exist. Depending on your retry and error handling strategy, this can cascade silently for a long time before anyone notices.
The fix is simple. The reason most people don't apply it is that the problem isn't obvious until you've seen it.
The Problem: Publishing Inside the Transaction
The intuitive approach is to publish to Kafka as part of the same transactional method:
@Transactional
public void process(SettlementWindow window, LocalDate today, Participant participant) {
// ...
FileBatch savedBatch = batchPort.save(batch);
orderPort.updateStatusBatch(orders);
// Publishes BEFORE the transaction commits
publisherPort.publish(savedBatch);
}
This looks safe. The transaction is still open, the data is there, everything is consistent — until the transaction rolls back.
If anything fails after publish() — another database update, a constraint violation, an unexpected exception — Spring rolls back the transaction. The database returns to its previous state. But Kafka already received the message. There is no rollback for Kafka.
The consumer now holds a reference to a FileBatch that does not exist in the database. This is a phantom message.
The Fix: afterCommit()
Spring's TransactionSynchronizationManager provides a hook that fires after the transaction has successfully committed:
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void process(SettlementWindow window, LocalDate today, Participant participant) {
// ...
FileBatch savedBatch = batchPort.save(batch);
orderPort.updateStatusBatch(orders);
// Kafka fires only after the database transaction is durable
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
publisherPort.publish(savedBatch);
log.info("FileBatch [{}] published to Kafka.", savedBatch.getId());
}
}
);
}
The difference is precise: afterCommit() runs after the database write is durable and outside the transaction boundary. If the transaction rolls back, the hook never executes. Kafka never sees the message.
This is not a performance optimization. It is a correctness guarantee.
The Producer Implementation
Once afterCommit() fires, the publish call reaches the Kafka producer:
@Component
public class FileBatchEmissionProducer implements FileBatchPublisherPort {
private final KafkaTemplate<String, String> kafkaTemplate;
private final ObjectMapper objectMapper;
private final String topic;
// constructor omitted for brevity
@Override
public void publish(FileBatch batch) {
String payload = objectMapper.writeValueAsString(BatchEmissionMessage.from(batch));
String key = batch.getWindow().getPartitioningKey();
kafkaTemplate
.send(topic, key, payload)
.whenComplete((result, ex) -> {
if (ex != null) {
log.error(
"Failed to publish FileBatch [{}] to topic [{}]: {}",
batch.getId(), topic, ex.getMessage(), ex
);
} else {
log.info(
"FileBatch [{}] published — topic [{}] partition [{}] offset [{}]",
batch.getId(),
topic,
result.getRecordMetadata().partition(),
result.getRecordMetadata().offset()
);
}
});
}
}
Two details worth noting here.
The partition key is the settlement window key — for example, STR-D1-17h00. This guarantees that all messages belonging to the same settlement window land on the same Kafka partition, preserving temporal ordering without any distributed locking. This connects directly to the first article in this series.
KafkaTemplate.send() is asynchronous. The whenComplete callback fires when the broker acknowledges the message — or when the send fails. This is an intentional design choice, and it comes with a trade-off that deserves to be stated clearly.
The Honest Trade-Off
afterCommit() solves the phantom message problem, but it introduces a different failure scenario: the database has committed, but the Kafka publish fails.
In that case, the batch exists in the database with status BATCHED, but no consumer ever receives the event to process it.
This is a known trade-off in systems that use the dual-write pattern — writing to both a database and a message broker without a distributed transaction spanning both. There is no free lunch here. You are choosing between two failure modes:
| Failure mode | Cause | Consequence |
|---|---|---|
| Phantom message | Publish before commit, transaction rolls back | Consumer processes non-existent data |
| Lost event | Publish after commit, Kafka send fails | Batch stuck in BATCHED, never processed |
Publishing after commit is the safer default. A lost event can be recovered — you can query for batches in BATCHED status and republish them. A phantom message is harder to detect and reason about, because the consumer has no way to know the data it received was never persisted.
For systems that need stronger guarantees, the Transactional Outbox pattern is the right answer — write the event to a database table within the same transaction, then relay it to Kafka asynchronously. But that adds operational complexity, and for many systems the simpler approach is sufficient.
What the Flow Looks Like End to End
@Transactional(REQUIRES_NEW) opens
→ batchPort.save() database write
→ orderPort.updateStatus() database write
→ registerSynchronization() registers afterCommit hook
@Transactional commits database is now durable
→ afterCommit() fires
→ publisherPort.publish()
→ KafkaTemplate.send() async broker write
→ whenComplete() logs partition + offset on success
logs error on failure
The separation is deliberate. The database commit and the Kafka publish are two distinct operations, and they are sequenced explicitly — not left to implicit transaction scope.
Takeaway
Publishing to Kafka inside a transaction is a subtle correctness bug. The transaction can roll back; Kafka cannot. The result is a message that points to data that doesn't exist.
TransactionSynchronizationManager.afterCommit() fixes this by deferring the publish until after the database write is durable. It is a small addition with a significant impact on system correctness.
The remaining trade-off — a failed Kafka send after a successful commit — is recoverable by design. Batches stuck in BATCHED status can be queried and republished. Phantom messages cannot be undelivered.
Sequence your side effects explicitly. Don't rely on transaction scope to do it for you.
This is part of a series on the STR-XML-Pipeline, a high-throughput interbank settlement system built with Spring Boot 3.5, Java 21, Apache Kafka, PostgreSQL 16, Redis 7, and AWS Fargate. The previous article covered bulk writing to PostgreSQL using the COPY protocol and CopyManager.
Top comments (0)