DEV Community

Cover image for Building a Clean Event Pipeline in Spring: From Simple Events to Async Listeners to the Outbox Pattern
Jayesh Shinde
Jayesh Shinde

Posted on

Building a Clean Event Pipeline in Spring: From Simple Events to Async Listeners to the Outbox Pattern

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

Now inside your service:

userEventPublisher.publish(new UserCreatedEvent(userId, email));
Enter fullscreen mode Exit fullscreen mode

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

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

And in the listener:

@Async
@EventListener
public void handleUserCreateEvent(UserCreatedEvent event) {
    // runs in a background thread
}
Enter fullscreen mode Exit fullscreen mode

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

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)

  1. 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
  2. A background process reads the outbox table

    This can be:

    • a scheduled Spring job
    • a Kafka Connect Debezium connector
    • a lightweight polling thread
  3. The background process publishes the event to Kafka

  4. 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 @EventListener to react to them
  • Realize enrichment logic slows down the request
  • Add @Async + @EnableAsync to 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)