Getting Started with EDA: Decoupling Spring Boot Applications using Spring Events

That “simple” change that just broke three unrelated modules? That isn’t a bug—it’s a symptom of a disease called tight coupling.

And every time you make a direct service call, you’re making the disease worse. Your codebase becomes a tangled web of dependencies, making every future change a nightmare of unpredictable side effects.

Most developers think the only cure is a massive leap to microservices and message brokers. But they often overlook a simpler, more powerful first step. There is a native Spring mechanism that allows you to sever these dependencies and start thinking in events today, within your existing monolith. It’s the first, and most crucial, step to writing resilient applications. Let’s start the treatment.

Understanding Event-Driven Architecture (EDA)

EDA is an architectural pattern centered around the production, detection, consumption, and reaction to events. An “event” represents a significant change in state—a fact that has occurred. For example, “Order Placed,” “User Registered,” or “Inventory Updated.”

The core philosophy is decoupling. Event producers publish events without knowing who the consumers are, or how the event will be used.

The Problem with Tight Coupling

Consider an e-commerce system. When an OrderService processes an order, several other actions need to happen: the InventoryService must update stock, and the NotificationService must email the customer.

In a tightly coupled approach, the OrderService calls the others directly:

@Transactional
public Order placeOrder(OrderRequest request) {
    Order order = saveOrder(request);

    // Tight Coupling: OrderService knows about InventoryService and NotificationService
    inventoryService.updateStock(order);
    notificationService.sendConfirmation(order);

    return order;
}

If the NotificationService is slow, the user waits longer. Crucially, adding a new requirement (e.g., updating analytics) requires modifying the core OrderService, violating the Open/Closed Principle (OCP).

Implementing EDA with Spring Events

While robust distributed systems utilize message brokers (like Kafka or RabbitMQ), you can begin applying EDA principles effectively within a single Spring Boot application (a modular monolith) using Spring’s built-in eventing mechanism.

This mechanism is a direct implementation of the Observer pattern, utilizing ApplicationEventPublisher to publish events and @EventListener to consume them.

1. Defining the Event

An event should carry the necessary data about the state change. Modern Java record types are ideal for defining immutable event data.

package com.example.events;

import java.time.Instant;

public record OrderPlacedEvent(
    Long orderId,
    String customerEmail,
    Instant timestamp
) {}

2. Publishing the Event

The producer injects the ApplicationEventPublisher and dispatches the event after completing its primary business action.

package com.example.order;

import com.example.events.OrderPlacedEvent;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;

@Service
public class OrderService {

    private final OrderRepository repository;
    private final ApplicationEventPublisher eventPublisher;

    // Constructor injection...

    @Transactional
    public Order placeOrder(OrderCommand command) {
        // Assume 'Order' is created and saved
        Order order = repository.save(new Order(command));

        // Publish the event
        eventPublisher.publishEvent(new OrderPlacedEvent(
            order.getId(),
            order.getCustomerEmail(),
            Instant.now()
        ));

        return order;
    }
}

3. Consuming the Event

Consumers listen for specific events using the @EventListener annotation.

package com.example.inventory;

import com.example.events.OrderPlacedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Service;

@Slf4j
@Service
public class InventoryService {

    @EventListener
    public void handleOrderPlaced(OrderPlacedEvent event) {
        log.info("Updating inventory for Order ID: {}", event.orderId());
        // ... inventory update logic
    }
}

The OrderService is now decoupled from the InventoryService. We can add new listeners (like an AnalyticsService) without touching the OrderService.

Mastering Transaction Boundaries

It is crucial to understand that, by default, Spring Events are synchronous. The publishEvent method blocks until all listeners have finished processing the event.

In the example above, the placeOrder method will not return, and the transaction will not commit, until InventoryService.handleOrderPlaced has completed.

Synchronous Events and Transactions

This synchronous nature is a powerful feature. It means that the listeners execute within the same transaction initiated by the publisher. If the InventoryService throws a runtime exception (e.g., insufficient stock), the entire transaction, including the saving of the order in OrderService, will be rolled back. This guarantees data consistency across different modules within the application.

Post-Commit Events with @TransactionalEventListener

Sometimes, this behavior is undesirable. You may only want a side effect to occur if the originating transaction successfully commits. For example, sending a notification email should only happen if the order is finalized, not if the transaction is rolled back due to a subsequent error.

Spring provides @TransactionalEventListener for fine-grained control over this behavior.

package com.example.notification;

import com.example.events.OrderPlacedEvent;
import org.springframework.transaction.event.TransactionalEventListener;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.stereotype.Service;

@Slf4j
@Service
public class NotificationService {

    // This listener executes only after the publisher's transaction commits successfully
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void handleOrderPlaced(OrderPlacedEvent event) {
        log.info("Transaction committed. Sending email to: {}", event.customerEmail());
        // ... email sending logic
    }
}

By default, the phase is AFTER_COMMIT. Other phases include BEFORE_COMMIT, AFTER_ROLLBACK, and AFTER_COMPLETION (commit or rollback).

Introducing Asynchronicity with @Async

While synchronous events provide transactional consistency, they do not offer temporal decoupling—the publisher is still blocked by the listener. If the InventoryService takes 5 seconds, the user waits 5 seconds longer.

To make a listener asynchronous, you must enable asynchronous processing in your Spring configuration (using @EnableAsync) and annotate the listener method with @Async.

// Configuration (e.g., in your main Application class)
@EnableAsync
@SpringBootApplication
public class Application { ... }

// Modified Listener
@Service
public class InventoryService {

    @Async
    @EventListener // Can also be combined with @TransactionalEventListener
    public void handleOrderPlaced(OrderPlacedEvent event) {
        // This now runs in a separate thread
        // ...
    }
}

By making the listener asynchronous, the OrderService can complete its transaction and respond to the user faster, while the inventory update happens in the background.

Conclusion

Event-Driven Architecture provides a robust pattern for designing resilient and decoupled systems. Spring Boot’s event mechanism offers an excellent, accessible entry point into EDA, allowing developers to decouple modules within a monolith effectively.

By understanding the interplay between ApplicationEventPublisher, @EventListener, @TransactionalEventListener, and @Async, developers can precisely control transaction boundaries and execution flow within a single application.

However, this mechanism is designed primarily for in-process communication. As we move towards distributed microservice architectures, we face new challenges regarding reliability, durability, and inter-service communication that Spring Events were not designed to handle.

In the next post, we will explore the limitations of Spring Events in a distributed context—specifically addressing issues like volatility and the JVM boundary—and discuss why reliable, durable messaging becomes essential for true microservice architecture.

The book cover of 'Future-Proof Your Java Career With Spring AI', a guide for enterprise Java developers on becoming AI Orchestrators.

Enjoyed this article? Take the next step.

Future-Proof Your Java Career With Spring AI

The age of AI is here, but your Java & Spring experience isn’t obsolete—it’s your greatest asset.

This is the definitive guide for enterprise developers to stop being just coders and become the AI Orchestrators of the future.

View on Amazon Kindle →

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.