close

DEV Community

Cover image for Your @EventListener Fires Before the Transaction Commits⚙️
Kyryl
Kyryl

Posted on

Your @EventListener Fires Before the Transaction Commits⚙️

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

The problem: @EventListener fires mid-transaction, before commit

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

The fix: @TransactionalEventListener fires after commit

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.
}
Enter fullscreen mode Exit fullscreen mode

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?

Top comments (0)