You can find the complete implementation in our github repo here.
Ever seen a codebase where business logic, database queries, and API endpoints are all tangled together? It starts clean, but over time, making a simple change becomes a high-risk operation. This is a common path for many applications, but it’s not the only one.
This post explores a powerful combination of architectural patterns—Domain-Driven Design (DDD) and Hexagonal Architecture (Ports and Adapters)—to build clean, decoupled, and maintainable systems. We’ll show how Spring Integration can act as the perfect plumbing to make this architecture a reality.
We’ll break down the concepts using a real-world prototype project that processes customer orders.
The Core Philosophy: Isolate Your Business Logic
The central idea is to build a “fortress” around your core business logic, protecting it from the messy details of the outside world (like databases, APIs, and message queues).
1. Domain-Driven Design (DDD)
At the heart of our approach is DDD. Instead of thinking about database tables or API endpoints first, we focus on the business domain itself.
- Ubiquitous Language: We use business terms directly in our code. An
Order
object in our code is the same concept the business team calls an “order.” - Aggregates: An
Order
is an “aggregate root,” a consistency boundary that ensures an order’s data is always valid. - Domain Events: When something important happens, we publish a
OrderPlacedEvent
. This signals a fact—”an order was placed”—that other parts of the system can react to.
2. Hexagonal Architecture (Ports and Adapters)
This is how we build the fortress. The “hexagon” is our core application, containing all the precious business logic. It interacts with the outside world through well-defined “ports.”
- The Hexagon (The Application Core):
- Domain Layer: Pure business logic and rules (e.g., the
Order
andOrderPlacedEvent
objects). It has zero knowledge of any framework. - Application Layer: Orchestrates the business logic. It defines the use cases of our application, like
PlaceOrderUseCase
.
- Domain Layer: Pure business logic and rules (e.g., the
- Ports (The Gates to the Fortress): These are simple interfaces.
- Inbound Ports: Define how to drive the application (e.g.,
PlaceOrderUseCase
). They are the “front door.” - Outbound Ports: Define what the application needs from the outside (e.g.,
NotificationPort
). They are the “service hatches.”
- Inbound Ports: Define how to drive the application (e.g.,
- Adapters (The Outside World): These are the concrete implementations that connect to our ports.
- Driving Adapters: A REST controller that takes an HTTP request and calls the
PlaceOrderUseCase
inbound port. - Driven Adapters: A component that implements the
NotificationPort
outbound port to send an actual email or SMS.
- Driving Adapters: A REST controller that takes an HTTP request and calls the
Spring Integration: The Powerhouse for Your Adapters
This is where Spring Integration shines. It provides a rich toolkit for building the “adapters” that connect our application core to external systems.
In our project, when an order is placed, the core application calls the NotificationPort
. The implementation of this port, NotificationAdapter
, doesn’t actually send an email. Instead, it uses a Spring Integration @MessagingGateway
to send the OrderPlacedEvent
to a message channel.
// A snippet from our NotificationAdapter
@Override
public void notify(OrderPlacedEvent event) {
notificationGateway.send(event); // Sends the event to a Spring Integration channel
}
This completely decouples the core logic from the notification mechanism. Now, we can use Spring Integration to define what happens to that event.
// A snippet from our IntegrationConfig
@Bean
public IntegrationFlow emailNotificationFlow() {
return IntegrationFlow.from("orderPlacedChannel")
.handle((payload, headers) -> {
System.out.println("EMAIL: Order placed: " + payload);
return null;
})
.get();
}
@Bean
public IntegrationFlow shippingNotificationFlow() {
return IntegrationFlow.from("orderPlacedChannel")
.handle((payload, headers) -> {
System.out.println("SHIPPING: Notifying shipping for order: " + payload);
return null;
})
.get();
}
We’ve defined two separate flows that listen to the same orderPlacedChannel
. One simulates sending an email, and the other simulates notifying the shipping department. We could add a third for analytics or a fourth for SMS without ever touching the core business logic.
Putting It All Together: The Flow of an Order
Here’s how the project is structured to reflect this architecture:
src/main/java/com/example/spring_integration_prototype/
├── application/ <-- Application Layer(UseCases,In/Out Ports)
├── domain/ <-- Domain Layer (Core Business Logic)
└── infrastructure/ <-- Infrastructure Layer (Adapters)
Let’s trace a request through the system:
- A client sends an HTTP POST request to our
/orders
endpoint. - The
OrderController
(a Driving Adapter) receives the request and calls thePlaceOrderUseCase
(an Inbound Port). - The
OrderService
(the hexagon’s application layer) creates anOrder
, runs business logic, and calls theNotificationPort
(an Outbound Port) with a newOrderPlacedEvent
. - The
NotificationAdapter
(a Driven Adapter) receives the event and sends it to theorderPlacedChannel
using Spring Integration. - The
emailNotificationFlow
andshippingNotificationFlow
(also Driven Adapters) both receive the event from the channel and execute their logic independently
Why Bother?
This architecture delivers incredible benefits:
- Maintainability: The core logic is pure and simple. You can change how notifications are sent (from email to SMS) or how you receive orders (from REST to Kafka) by just swapping out an adapter. The core remains untouched.
- Testability: The domain and application layers can be tested in isolation without needing any frameworks, web servers, or databases.
- Flexibility: The business can evolve. Adding a new subscriber to the
OrderPlacedEvent
is trivial and doesn’t risk breaking existing functionality.
It’s a blueprint for building applications that can evolve and adapt without collapsing under their own weight.
Leave a Reply