Event-Driven Communication in Spring Boot: Decoupling in Practice
In our previous article about Spring Modulith, we touched on a crucial point for avoiding the dreaded "spaghetti code": Event-Driven Communication. But did you know you don't need Kafka or RabbitMQ to start working with events? Many developers assume that events are synonymous with microservices and distributed messaging. The truth is that Spring Boot has a native, powerful mechanism for publishing and listening to events within the same JVM.
1. The Basics: Publishing and Listening to Events
The premise is simple: when something important happens in your system (e.g., "Order Created"), instead of directly calling the classes responsible for generating invoices or sending emails, you simply shout to the system that the event occurred. Whoever is interested can react to it.
The Event (Using Java Records):
package com.alexsousadev.order;
public record OrderCreatedEvent(String orderId, String customerEmail) {}
The Publisher:
package com.alexsousadev.order;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final ApplicationEventPublisher publisher;
public OrderService(ApplicationEventPublisher publisher) {
this.publisher = publisher;
}
public void createOrder(String orderId, String email) {
// Business logic and database persistence...
System.out.println("Order saved to database: " + orderId);
// Publish the event
publisher.publishEvent(new OrderCreatedEvent(orderId, email));
}
}
The Listener:
package com.alexsousadev.notification;
import com.alexsousadev.order.OrderCreatedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class EmailNotificationListener {
@EventListener
public void handleOrderCreated(OrderCreatedEvent event) {
System.out.println("Sending confirmation email to: " + event.customerEmail());
}
}
2. The Transaction Trap (Common Mistakes)
Tip / The Real-World Problem: Using a simple
@EventListenerposes a huge risk. If yourOrderServicesaves the order, triggers the event, but immediately throws an error (e.g., a database constraint violation), the transaction rolls back. The problem? The listener already sent the email! The customer will receive a confirmation for an order that doesn't exist in the database.
The solution in Spring Boot is to replace @EventListener with @TransactionalEventListener. This binds the event execution to the publisher's database transaction.
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
@Component
public class EmailNotificationListener {
// The event will only be processed if (and only if) the database commit is successful
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleOrderCreated(OrderCreatedEvent event) {
System.out.println("Safely sending email to: " + event.customerEmail());
}
}
3. Asynchronicity: Freeing the Main Thread
By default, Spring event listeners are synchronous. This means the user who clicked "Buy" will wait for the main thread to finish saving to the database, go send the email, and only then receive the 200 OK HTTP response.
To solve this and keep response times fast, we need to run the listener in another thread.
Step-by-step:
Add
@EnableAsyncto your main application class (or a configuration class).Annotate your Listener method with
@Async:
import org.springframework.scheduling.annotation.Async;
import org.springframework.transaction.event.TransactionalEventListener;
@Component
public class EmailNotificationListener {
@Async
@TransactionalEventListener
public void handleOrderCreatedAsync(OrderCreatedEvent event) {
// This code now runs in a separate background thread
System.out.println("Processing email dispatch in the background...");
}
}
Conclusion
Implementing internal (In-VM) event-driven communication using ApplicationEventPublisher and @TransactionalEventListener is the first major step toward a Modular Monolith. You remove rigid coupling (unnecessary dependency injections), improve testability, and prepare your codebase to eventually plug in a real message broker without having to rewrite your entire domain logic.
Recommended book
Want to dive deeper into this topic? Check out the book that inspired this post.
View book →