{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreibpovpwoj35sxobaryxvfypaci46ncofijwczqalyjv5kad3izkr4",
"uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mp2vh2r67vz2"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreieiy6a7z2f5nbgkqhrw45n5fbc4i3birusbd2ptv7h3h34wfebgai"
},
"mimeType": "image/webp",
"size": 61542
},
"path": "/code_with_kyryl/your-eventlistener-fires-before-the-transaction-commits-286m",
"publishedAt": "2026-06-24T21:20:49.000Z",
"site": "https://dev.to",
"tags": [
"java",
"springboot",
"events",
"transactions",
"@EventListener",
"@Component",
"@TransactionalEventListener",
"@Transactional"
],
"textContent": "Your domain event fires. Your notification service queries the DB for the entity that just got saved. It finds nothing.\n\nYou add a log line. It starts working. You remove the log. It breaks again.\n\nThat's not a race condition. That's `@EventListener`.\n\n## What's actually happening\n\nSpring'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.\n\nThe 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`.\n\nHere's the broken setup:\n\n\n\n @Component\n public class OrderEventListener {\n\n @EventListener // fires MID-TRANSACTION, before commit\n public void onOrderCreated(OrderCreatedEvent event) {\n // Transaction not committed yet.\n // Other DB connections see nothing.\n Order order = orderRepository\n .findById(event.getOrderId())\n .orElseThrow(); // ← throws here, row doesn't exist yet\n\n notificationService.notifyCustomer(order);\n }\n }\n\n\n## The obvious fix and what it costs you\n\nSpring 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.\n\n\n\n @Component\n public class OrderEventListener {\n\n @TransactionalEventListener(\n phase = TransactionPhase.AFTER_COMMIT\n )\n public void onOrderCreated(OrderCreatedEvent event) {\n // Transaction committed. All connections see the row.\n Order order = orderRepository\n .findById(event.getOrderId())\n .orElseThrow(); // ← works fine\n\n notificationService.notifyCustomer(order);\n }\n }\n\n\nBut 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.\n\n`@EventListener`: stale reads.\n`@TransactionalEventListener(AFTER_COMMIT)`: silent data loss on listener failure.\n\nNeither is great.\n\n## The edge case that bites in tests\n\nThere'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.\n\nCall 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.\n\nFix: `fallbackExecution = true`.\n\n\n\n @TransactionalEventListener(\n phase = TransactionPhase.AFTER_COMMIT,\n fallbackExecution = true // fires even with no active transaction\n )\n public void onOrderCreated(OrderCreatedEvent event) {\n // Now works from Kafka consumers, tests, scheduled tasks\n // that don't have an active @Transactional context.\n // Without this: event silently dropped. Nothing tells you.\n }\n\n\nThis 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.\n\n## When AFTER_COMMIT is fine and when it isn't\n\nThe real question is: what happens if the listener never fires?\n\nIf the answer is \"stale cache for 60 seconds\" or \"audit log has a gap\" — `AFTER_COMMIT` is fine. The business isn't broken.\n\nIf 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.\n\nThe outbox is more infrastructure. But it's the correct choice when losing an event corrupts state.\n\n## The trade-off, summarised\n\nApproach | Stale reads | Silent loss on failure | Works outside `@Transactional`\n---|---|---|---\n`@EventListener` | Yes | No | Yes\n`@TransactionalEventListener(AFTER_COMMIT)` | No | Yes | No (silent drop)\n`@TransactionalEventListener(AFTER_COMMIT, fallbackExecution = true)` | Mixed | Yes | Yes\nOutbox pattern | No | No | Yes\n\n`@EventListener` vs `@TransactionalEventListener` — almost identical names, completely different behavior. Most teams find this difference via a production incident, not the docs.\n\nHow do you handle post-commit side effects in your services?",
"title": "Your @EventListener Fires Before the Transaction Commits⚙️"
}