Your domain event fires. Your notification service queries the DB for the entity that just got saved. It finds nothing.
You add a log line. It starts working. You remove the log. It breaks again.
That's not a race condition. That's @EventListener.
What's actually happening
Spring's @EventListener fires synchronously, inside the calling thread, before the transaction commits. The DB row exists in Hibernate's session — but it hasn't been flushed and committed yet. Other connections, including the one your listener opens when it calls findById, can't see it.
The log statement "fixes" it because the delay gives Hibernate time to flush. Remove the log, the flush doesn't happen in time, and you're back to an empty Optional.
Here's the broken setup:
@Component
public class OrderEventListener {
@EventListener // fires MID-TRANSACTION, before commit
public void onOrderCreated(OrderCreatedEvent event) {
// Transaction not committed yet.
// Other DB connections see nothing.
Order order = orderRepository
.findById(event.getOrderId())
.orElseThrow(); // ← throws here, row doesn't exist yet
notificationService.notifyCustomer(order);
}
}
The obvious fix and what it costs you
Spring ships @TransactionalEventListener for exactly this. Set phase = TransactionPhase.AFTER_COMMIT and the listener fires after the transaction commits. The row is visible. findById returns the order. Problem solved.
@Component
public class OrderEventListener {
@TransactionalEventListener(
phase = TransactionPhase.AFTER_COMMIT
)
public void onOrderCreated(OrderCreatedEvent event) {
// Transaction committed. All connections see the row.
Order order = orderRepository
.findById(event.getOrderId())
.orElseThrow(); // ← works fine
notificationService.notifyCustomer(order);
}
}
But the trade-off is real. Your listener is now decoupled from the transaction. If the listener fails — notification service is down, the email throws, the external API times out — the transaction already committed. The event is gone. Nothing retries it. Nothing tells you it was dropped.
@EventListener: stale reads.
@TransactionalEventListener(AFTER_COMMIT): silent data loss on listener failure.
Neither is great.
The edge case that bites in tests
There's a second problem with @TransactionalEventListener that most teams hit in tests or Kafka consumers: if there's no active transaction, the listener silently does nothing.
Call the service from a unit test without @Transactional. Publish a Kafka message that triggers the same service method without a transaction boundary. The listener won't fire. No warning. No exception. The event just disappears.
Fix: fallbackExecution = true.
@TransactionalEventListener(
phase = TransactionPhase.AFTER_COMMIT,
fallbackExecution = true // fires even with no active transaction
)
public void onOrderCreated(OrderCreatedEvent event) {
// Now works from Kafka consumers, tests, scheduled tasks
// that don't have an active @Transactional context.
// Without this: event silently dropped. Nothing tells you.
}
This restores synchronous execution when there's no transaction — which gives you back the mid-transaction timing problem you started with. You're going in circles.
When AFTER_COMMIT is fine and when it isn't
The real question is: what happens if the listener never fires?
If the answer is "stale cache for 60 seconds" or "audit log has a gap" — AFTER_COMMIT is fine. The business isn't broken.
If the answer is "customer didn't get charged", "duplicate order created", or "inventory not decremented" — you need the outbox pattern. Write the event as a row in an outbox table inside the same transaction. A separate process (a scheduler or Debezium reading the WAL) picks it up and publishes it after commit. Now the event delivery is reliable and tied to the transaction at the DB level, not the application level.
The outbox is more infrastructure. But it's the correct choice when losing an event corrupts state.
The trade-off, summarised
| Approach | Stale reads | Silent loss on failure | Works outside @Transactional
|
|---|---|---|---|
@EventListener |
Yes | No | Yes |
@TransactionalEventListener(AFTER_COMMIT) |
No | Yes | No (silent drop) |
@TransactionalEventListener(AFTER_COMMIT, fallbackExecution = true) |
Mixed | Yes | Yes |
| Outbox pattern | No | No | Yes |
@EventListener vs @TransactionalEventListener — almost identical names, completely different behavior. Most teams find this difference via a production incident, not the docs.
How do you handle post-commit side effects in your services?














