Event‑driven architecture sounds simple on paper: “emit an event when something happens.”
But once you start implementing it inside a real Spring Boot service, you quickly discover the hidden trade‑offs.
In this post, we’ll walk through a real‑world progression:
- emitting domain events inside a service
- handling them with
@EventListener - realizing enrichment logic slows down the request
- making listeners async
- adding a production‑grade executor
- and finally touching the gold standard: the Outbox Pattern
Let’s dive in.
1. The initial requirement: emit an event inside the service
Imagine a simple use case: when a user is created, we want to emit an event so other parts of the system can react.
A clean way to do this in Spring is to wrap ApplicationEventPublisher:
@Component
@AllArgsConstructor
public class UserEventPublisher {
private final ApplicationEventPublisher applicationEventPublisher;
public <T> void publish(T event) {
applicationEventPublisher.publishEvent(event);
}
}
Now inside your service:
userEventPublisher.publish(new UserCreatedEvent(userId, email));
2. Handling the event with @EventListener
A simple listener:
@Component
public class UserAuditListener {
@EventListener
public void handleUserCreateEvent(UserCreatedEvent event) {
System.out.println("User created: " + event);
}
}
This works beautifully… until you need to do more than just print.
3. The problem: enrichment logic slows down the request
Let’s say before publishing to Kafka, you want to:
- fetch additional data from DB
- call another service
- enrich the event payload
Since @EventListener is synchronous by default, all this work blocks the original request thread.
Your API response time suddenly spikes.
Not good.
4. Making listeners async with @Async and @EnableAsync
Spring makes this easy:
@EnableAsync
@SpringBootApplication
public class App { }
And in the listener:
@Async
@EventListener
public void handleUserCreateEvent(UserCreatedEvent event) {
// runs in a background thread
}
Now the main request returns immediately while the listener does its work asynchronously.
But there’s a catch…
5. The default executor is not production‑grade
If you don’t configure anything, Spring uses SimpleAsyncTaskExecutor:
- creates a new thread per task
- no pooling
- no backpressure
- no monitoring
This is fine for demos, not for real systems.
6. Adding a custom executor
A better approach is to define your own thread pool:
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "taskExecutor")
public Executor taskExecutor() {
return Executors.newCachedThreadPool();
}
}
Now all @Async methods use this executor.
You can replace it with a tuned ThreadPoolTaskExecutor for even more control.
7. The gold standard: the Outbox Pattern
Async listeners solve the latency problem, but they don’t solve the reliability problem.
What if:
- the DB transaction commits
- but the async listener fails before sending to Kafka?
- or the service crashes?
You lose the event.
This is why mature systems use the Outbox Pattern.
How the Outbox Pattern works (high‑level)
-
Write the event into an “outbox” table inside the same DB transaction
- If the user is created, the outbox record is also created
- Atomic, consistent, no partial failures
-
A background process reads the outbox table
This can be:- a scheduled Spring job
- a Kafka Connect Debezium connector
- a lightweight polling thread
The background process publishes the event to Kafka
After successful publish, the outbox record is marked as processed
Why this is the gold standard
- no lost events
- no double‑publishing
- no dependency on async listeners
- fully decoupled from request latency
- battle‑tested at Uber, Netflix, Stripe, Shopify
8. Summary
Here’s the journey we walked through:
- Start with simple Spring events
- Add
@EventListenerto react to them - Realize enrichment logic slows down the request
- Add
@Async+@EnableAsyncto make listeners non‑blocking - Add a custom executor for production‑grade async processing
- Finally, adopt the Outbox Pattern for guaranteed delivery and reliability
This progression mirrors how real systems evolve as they scale.
If you’re building event‑driven microservices, the outbox pattern is the foundation you eventually want to reach.
Top comments (0)