Event-Driven Architecture with Spring Boot: Building Scalable and Responsive Systems
In this post, I explore how to implement EDA using Spring Boot, complete with practical examples and best practices. It’s a little bit long becasue of code snippets that I had to strip down just to illustratw.
What is Event-Driven Architecture?
Event-Driven Architecture is a software design pattern that promotes the production, detection, consumption, and reaction to events.
An event is a significant change in state that occurs at a point in time. For example:
- A user places an order
- A payment is processed successfully
- Inventory stock drops below a threshold
In EDA, components communicate through events rather than direct API calls, leading to looser coupling and better scalability.
Key Benefits of EDA
- Loose Coupling: Services don’t need to know about each other
- Scalability: Components can be scaled independently
- Resilience: Failure in one service doesn’t necessarily break the entire system
- Responsiveness: Systems can react to events in real-time
- Extensibility: New consumers can be added without modifying producers
Implementing EDA with Spring Boot
Spring Boot provides excellent support for building event-driven systems through Spring Events and integrations with messaging platforms like Kafka or RabbitMQ.
1. Basic Spring Events
Spring Framework has a built-in event mechanism that works within a single application context:
// 1. Define a custom event
public class OrderCreatedEvent extends ApplicationEvent {
private final Order order;
public OrderCreatedEvent(Object source, Order order) {
super(source);
this.order = order;
}
public Order getOrder() {
return order;
}
}
// 2. Create an event publisher
@Service
public class OrderService {
private final ApplicationEventPublisher eventPublisher;
public OrderService(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
public Order createOrder(OrderRequest request) {
Order order = // create order logic
eventPublisher.publishEvent(new OrderCreatedEvent(this, order));
return order;
}
}
// 3. Create an event listener
@Component
public class OrderEventListener {
@EventListener
public void handleOrderCreatedEvent(OrderCreatedEvent event) {
// Process the event, e.g., send notification, update inventory, etc.
Order order = event.getOrder();
System.out.println("Order created: " + order.getId());
}
}
2. Using ApplicationEventPublisher
For more complex scenarios, you can use the ApplicationEventPublisher directly:
@Component
public class CustomEventPublisher {
private final ApplicationEventPublisher applicationEventPublisher;
public CustomEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
this.applicationEventPublisher = applicationEventPublisher;
}
public void publishCustomEvent(final String message) {
System.out.println("Publishing custom event. ");
CustomEvent customEvent = new CustomEvent(this, message);
applicationEventPublisher.publishEvent(customEvent);
}
}
public class CustomEvent extends ApplicationEvent {
private String message;
public CustomEvent(Object source, String message) {
super(source);
this.message = message;
}
public String getMessage() {
return message;
}
}
3. Asynchronous Event Processing
To process events asynchronously and avoid blocking the main thread:
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean(name = "eventTaskExecutor")
public TaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(25);
executor.setThreadNamePrefix("event-executor-");
executor.initialize();
return executor;
}
}
@Component
public class AsyncEventListener {
@Async("eventTaskExecutor")
@EventListener
public void handleAsyncEvent(OrderCreatedEvent event) {
// Process event asynchronously
System.out.println("Processing order asynchronously: " + event.getOrder().getId());
}
}
4. Transaction-Bound Events
Spring allows you to publish events that are tied to transaction phases:
@Service
public class OrderService {
private final ApplicationEventPublisher eventPublisher;
public OrderService(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
@Transactional
public Order createOrder(OrderRequest request) {
Order order = // create order logic
// Event will be published only if transaction commits successfully
eventPublisher.publishEvent(new OrderCreatedEvent(this, order));
return order;
}
}
// Listen for events only after transaction commit
@Component
public class TransactionalEventListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleAfterCommit(OrderCreatedEvent event) {
// This will execute only after the transaction commits
System.out.println("Order created after transaction commit: " + event.getOrder().getId());
}
}
5. Integrating with Messaging Systems (Kafka Example)
For distributed systems, you’ll want to use a messaging platform like Kafka, RabbitMQ, or AWS SNS/SQS:
// Add to pom.xml
// <dependency>
// <groupId>org.springframework.kafka</groupId>
// <artifactId>spring-kafka</artifactId>
// </dependency>
@Configuration
public class KafkaConfig {
@Value("${spring.kafka.bootstrap-servers}")
private String bootstrapServers;
@Bean
public ProducerFactory<String, String> producerFactory() {
Map<String, Object> configProps = new HashMap<>();
configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);
return new DefaultKafkaProducerFactory<>(configProps);
}
@Bean
public KafkaTemplate<String, String> kafkaTemplate() {
return new KafkaTemplate<>(producerFactory());
}
}
@Service
public class KafkaEventPublisher {
private final KafkaTemplate<String, Object> kafkaTemplate;
public KafkaEventPublisher(KafkaTemplate<String, Object> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public void publishOrderCreatedEvent(Order order) {
kafkaTemplate.send("order-created-topic", order);
}
}
@Component
public class KafkaEventListener {
@KafkaListener(topics = "order-created-topic", groupId = "notification-service")
public void listenOrderCreatedEvent(Order order) {
System.out.println("Received Order Created Event: " + order.getId());
// Process the event
}
}
Best Practices for Event-Driven Architecture
- Design Events Carefully: Events should represent facts about something that happened, not commands.
- Use Schema Evolution: Plan for how your events will evolve over time without breaking consumers.
- Implement Idempotency: Make sure processing the same event multiple times doesn’t cause duplicate side effects.
- Monitor Event Flows: Add comprehensive monitoring and logging for producers and consumers.
- Consider Event Sourcing: For critical operations, store all state changes as a sequence of events.
- Handle Dead Letter Queues: Plan for unprocessable events to avoid data loss.
- Secure Your Events: Protect sensitive data inside events with proper encryption and access control.
Common Pitfalls to Avoid
- Over-engineering: Not every system needs event-driven architecture.
- Event Spaghetti: Too many events can make the system hard to understand and debug.
- Lack of Monitoring: Without observability, async systems quickly become black boxes.
- Ignoring Event Ordering: Some workflows require strict sequencing—don’t overlook this.
- Infinite Loops: Avoid circular event triggers that can cause runaway processing.
📌 Follow along weekly right here or catch me on LinkedIn