Simplifying Complexity with Event-Driven Architecture
How to Use Events to Build Modular and Maintainable Applications
Have you ever faced the challenge of creating an API that takes a long time to proceed? As the project expands, an API might require multiple processes to do something. Let me use the story below to help you understand this complex scenario.
Imagine there is a pizza restaurant named “Pizza House.” When a customer places an order, multiple things need to happen:
- The kitchen needs to start making the pizza.
- The cashier needs to process the payment.
- The delivery staff needs to be alerted to deliver the pizza.
- The order details need to be recorded for our records.
Initially, the person taking the order would handle everything themselves, like this:
- Take the order.
- Run to the kitchen to tell them to start making the pizza.
- Run back to process the payment.
- Call the delivery staff.
- Write down the order details.
This approach works, but it becomes problematic as the business grows:
- Inefficient: The order taker needs to spend more time running around.
- Error-Prone: The order taker can easily make mistakes, forget steps, or get overwhelmed during busy hours.
- Poor Scalability: Adding more tasks, like sending thank-you messages to the customer, makes the process even more complicated.
To solve these issues, Pizza House introduces the bell system:
- Kitchen Bell: When an order is placed, the order taker rings the kitchen bell, signaling the kitchen to start making the pizza.
- Payment Bell: Another bell alerts the cashier to process the payment.
- Delivery Bell: A separate bell alerts the delivery staff.
- Recording Bell: A bell signals the recorder to log the order details.
By using the bell system, the order taker can focus on taking the order and ringing the bells. Besides that, each department handles its tasks when its bell rings.
While Pizza House implemented the bell system, the concept was called an asynchronous system. In programming, it is called an event-driven architecture.
What is Event-Driven Architecture?
Event-driven architecture (EDA) is a software design pattern in which the system reacts to specific events rather than rather than continuously polling for changes. In this concept, events are messages sent asynchronously between components, and the components respond to the events they are interested in.
Usually, EDA has three main models:
- Event: A message or signal that something has occurred within the system. It’s a way to notify other parts of the application that a particular action or state change has occurred.
- Event Publisher (Producer): It is responsible for creating and broadcasting events. When an event occurs, the publisher generates an event object and sends it to all registered listeners.
- Event Listeners (Consumer): A component waits for an event to be fired and then reacts. Listeners contain the logic to handle specific types of events.
Using events in Spring Boot comes with several advantages and disadvantages. Understanding these can help you decide when to use events and listeners in your application.
Advantages
- Decoupling Components: Events decouple the sender and receiver, allowing different parts of the application to interact without knowing about each other.
- Scalability: Event-driven systems can be more scalable as different parts of the system can handle their tasks independently.
- Extensibility: New functionality can be added by adding new event listeners without modifying existing code.
- Separation of Concerns: This helps maintain a clean separation of concerns, making the codebase easier to understand and maintain.
- Flexibility: It allows for more flexible application design, especially when dealing with complex workflows or integrating external systems.
Disadvantages
- Increased Complexity: Event-driven architectures can introduce additional complexity, making the system harder to understand and debug.
- Asynchronous Processing Pitfalls: While asynchronous processing can be an advantage, it can also lead to issues like race conditions, inconsistent states, and difficulty in error handling.
- Event Management Overhead: Managing events and listeners requires careful handling, especially as the number of events and listeners grows.
- Latency: Asynchronous events can introduce latency. There can be a delay between the published event and the listener processing it.
- Testing Challenges: Testing event-driven systems can be more complex. Simulating events and verifying that listeners react as expected can be more complicated.
How to Implement EDA
We’ll create a simple user registration system that triggers various actions using events. This code will include defining events, creating event publishers, and implementing event listeners.
First, create a custom event class that extends ApplicationEvent
. This class will provide information about the event.
package com.xtmd.registration.event;
import org.springframework.context.ApplicationEvent;
public class UserRegisteredEvent extends ApplicationEvent {
private final String userEmail;
public UserRegisteredEvent(Object source, String userEmail) {
super(source);
this.userEmail = userEmail;
}
public String getUserEmail() {
return userEmail;
}
}
In the registration service, publish the event whenever a new user registers. Use ApplicationEventPublisher
to publish the event.
package com.xtmd.registration.service;
import com.xtmd.registration.event.UserRegisteredEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
@Service
public class UserService {
private final ApplicationEventPublisher eventPublisher;
public UserService(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
public void registerUser(String email) {
// Logic to save the user to the database
userRepository.save(user);
// Publish the event
eventPublisher.publishEvent(new UserRegisteredEvent(this, email));
}
}
Then, create listeners to handle UserRegisteredEvent
. Each listener will be a Spring component with a method annotated with @EventListener
.
Welcome Email Listener
package com.xtmd.registration.listener;
import com.xtmd.registration.event.UserRegisteredEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class WelcomeEmailListener {
@EventListener
public void handleUserRegisteredEvent(UserRegisteredEvent event) {
String userEmail = event.getUserEmail();
// Logic to send a welcome email
System.out.println("Sending welcome email to " + userEmail);
}
}
Registration Logging Listener
package com.xtmd.registration.listener;
import com.xtmd.registration.event.UserRegisteredEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class RegistrationLoggingListener {
@EventListener
public void handleUserRegisteredEvent(UserRegisteredEvent event) {
String userEmail = event.getUserEmail();
// Logic to log the event
System.out.println("User registered with email " + userEmail);
}
}
Now, whenever new registered users are added, the application logs their registration and sends a welcome email.
Why Choose Event-Driven Architecture (EDA) Over Multithreading
There are two options for handling asynchronous tasks: Event-Driven Architecture and Multithreading. Both can handle asynchronous tasks and improve performance, but they are fundamentally different concepts with distinct use cases and implementations.
While EDA is a design pattern in which events determine the program’s flow, multithreading is a technique in which multiple threads are used within a single application to execute tasks concurrently.
Imagine you’re working on a complex system that needs to handle various tasks efficiently and scalably. While multithreading can provide some performance benefits, EDA offers a more robust, flexible, and maintainable approach. Here’s why EDA might be the better choice for your projects:
Decoupling and Modularity
Multithreading involves tightly coupled threads sharing memory, which can lead to complex and error-prone code. EDA decouples components, allowing them to operate independently. It means that one part of the system doesn’t directly impact others, making it more modular.
Scalability
Multithreading is limited by the number of threads your system can efficiently manage and the number of CPU cores available. But, EDA allows you to scale different parts of your system independently. For example, if the load on your email service increases, you can add more listeners without affecting other components.
Flexibility and Extensibility
Multithreading requires careful synchronization and management of shared resources, making it harder to add new features. With EDA, adding new functionality is straightforward. You can add new event listeners without modifying existing code, making it easy to extend your application.
Maintainability
Multithreading can lead to complicated and tangled code due to the need for thread management and synchronization. In contrast, EDA promotes clean separation of concerns. Each listener handles a specific task, making your codebase easier to understand, maintain, and test.
Handling Complex Workflows
Multithreading focuses on parallel execution but doesn’t inherently provide a way to manage complex, interdependent workflows. Otherwise, EDA is ideal for applications that handle complex workflows where multiple actions occur in response to a single event. It simplifies the flow and makes it more manageable.
You can see the summary in the table below:
While multithreading can offer performance benefits for CPU-bound tasks, Event-Driven Architecture (EDA) provides a superior approach for building scalable, maintainable, and flexible systems. By decoupling components, promoting modularity, and simplifying complex workflows, EDA enables you to build robust applications that can quickly adapt and grow with your needs.
Embrace EDA for a more efficient and future-proof system design. Your team will thank you for the improved clarity, flexibility, and scalability it brings to your projects.