A Practical Guide to Hexagonal Architecture and Spring Integration

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 and OrderPlacedEvent objects). It has zero knowledge of any framework.
    • Application Layer: Orchestrates the business logic. It defines the use cases of our application, like PlaceOrderUseCase.
  • 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.”
  • 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.

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:

  1. A client sends an HTTP POST request to our /orders endpoint.
  2. The OrderController (a Driving Adapter) receives the request and calls the PlaceOrderUseCase (an Inbound Port).
  3. The OrderService (the hexagon’s application layer) creates an Order, runs business logic, and calls the NotificationPort (an Outbound Port) with a new OrderPlacedEvent.
  4. The NotificationAdapter (a Driven Adapter) receives the event and sends it to the orderPlacedChannel using Spring Integration.
  5. The emailNotificationFlow and shippingNotificationFlow (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.


Posted

in

, , , ,

by

Comments

Leave a Reply

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