Target Audience: Developers considering testability and external dependency replacement Prerequisites: Understanding the limitations of Layered Architecture Estimated Time: About 20 minutes

Also known as the Ports and Adapters pattern. An architecture that completely isolates the application core from the external world. The core idea of hexagonal architecture is to place business logic at the center and handle all external interactions through Ports and Adapters. This way, even if external technologies change, core business logic remains unaffected.

One-Line Summary#

The application sits inside the hexagon, and all external connections are handled through Ports and Adapters. This allows you to perfectly separate business logic from technical details.

flowchart TB
    subgraph External["External World"]
        WEB["Web"]
        CLI["CLI"]
        DB[("Database")]
        API["External API"]
    end

    subgraph Adapters["Adapters (Connectors)"]
        WA["Web Adapter"]
        CA["CLI Adapter"]
        PA["Persistence Adapter"]
        EA["External API Adapter"]
    end

    subgraph Ports["Ports (Connection Specs)"]
        IP["Inbound Port"]
        OP["Outbound Port"]
    end

    subgraph Core["Application Core"]
        APP["Application Service"]
        DOM["Domain Model"]
    end

    WEB --> WA --> IP
    CLI --> CA --> IP
    IP --> APP --> DOM
    APP --> OP
    OP --> PA --> DB
    OP --> EA --> API

Why “Hexagonal”?#

The hexagonal name does not mean 6 sides are important, but rather conveys that the application can be connected from multiple directions. Unlike the one-way flow of “top to bottom” in layered architecture, hexagonal presents the perspective of “inside and outside.”

Analogy: Smartphone and Adapters

Think about a smartphone. The smartphone itself does not know which charger it uses.

  • Smartphone (Core): Handles only core functions like calls and app execution
  • Charging Port (Port): An interface that says “I want to receive power”
  • Adapter: Connects via various methods like USB-C, wireless charging, USB

Even if the charging method changes, the phone’s functions remain the same. Just changing the adapter lets you connect to various devices.

flowchart TB
    subgraph Phone["Smartphone (Core)"]
        OS["OS"]
        APP["Apps"]
    end

    subgraph Adapters["Connectors"]
        CHG["Charging Adapter"]
        HDMI["HDMI Adapter"]
        USB["USB Adapter"]
        BT["Bluetooth"]
    end

    subgraph External["External Devices"]
        POWER["Power"]
        TV["TV"]
        PC["Computer"]
        SPEAKER["Speaker"]
    end

    POWER --> CHG --> Phone
    TV <--> HDMI <--> Phone
    PC <--> USB <--> Phone
    SPEAKER <--> BT <--> Phone

The same applies to software. Core business logic does not need to know which database or UI framework is used. All these technical choices are isolated through Adapters.

The hexagonal shape does not mean 6 sides are important, but visually represents “can be connected from multiple directions.” The key is shifting thinking from the layered “top->bottom” to an “inside<->outside” perspective.


Three Core Concepts#

To understand hexagonal architecture, you need to know three concepts: Port, Adapter, and Application Core. Understanding how these three collaborate reveals the full picture of hexagonal architecture.

1. Port - “Connection Spec”

A Port is an interface. It defines the “specification” for connecting with the outside. There are two types of Ports. Inbound Ports define incoming requests from external to application, and Outbound Ports define outgoing requests from application to external.

flowchart LR
    subgraph InboundPorts["Inbound Ports (Incoming Requests)"]
        IP1["CreateOrderUseCase"]
        IP2["GetOrderQuery"]
    end

    subgraph OutboundPorts["Outbound Ports (Outgoing Requests)"]
        OP1["SaveOrderPort"]
        OP2["SendNotificationPort"]
    end

    EXT1["External Request"] --> InboundPorts
    OutboundPorts --> EXT2["External System"]

Understanding the two types of Ports is important. The table below summarizes each Port’s characteristics.

Port TypeDirectionRoleExample
Inbound PortExternal -> Application“Request me like this”CreateOrderUseCase
Outbound PortApplication -> External“I only need this”SaveOrderPort

Inbound Ports define how the application can be called from outside. For example, it specifies “you need this information to create an order.” Outbound Ports define what the application requests from the outside. It specifies “I want to save an order” but does not need to know which database or how to store it.

// Inbound Port: "Call me like this from outside"
public interface CreateOrderUseCase {
    OrderId createOrder(CreateOrderCommand command);
}

// Outbound Port: "I want to save an order"
public interface SaveOrderPort {
    void save(Order order);
}

By defining Ports as interfaces, you do not need to know what the specific implementation is. Even if you later switch MySQL to MongoDB or REST API to gRPC, the Ports do not need to change.

2. Adapter - “Connector”

An Adapter is an implementation. It handles the actual connection according to the Port specification. There are also two types of Adapters. Driving Adapters call the application, and Driven Adapters are called by the application.

flowchart LR
    subgraph Driving["Driving Adapters (Call me)"]
        WA["Web Adapter<br>(Controller)"]
        CA["CLI Adapter"]
        MA["Message Adapter<br>(Kafka Listener)"]
    end

    subgraph Driven["Driven Adapters (I call)"]
        PA["Persistence Adapter<br>(Repository Impl)"]
        NA["Notification Adapter<br>(Email, SMS)"]
        EA["External API Adapter"]
    end

Distinguishing the two types of Adapters is important. The table below summarizes each Adapter’s characteristics.

Adapter TypeOther NameRoleExample
Driving AdapterPrimary AdapterCalls the applicationController, CLI
Driven AdapterSecondary AdapterCalled by the applicationRepository impl, API Client

Driving Adapters receive external requests and pass them to the application. For example, an HTTP Controller receives HTTP requests and converts them to Inbound Port format. Driven Adapters pass application requests to external systems. For example, a JPA Repository converts application save requests into database queries.

// Driving Adapter: Receive external requests and pass to application
@RestController
public class OrderController {
    private final CreateOrderUseCase createOrderUseCase;  // Uses Port

    @PostMapping("/orders")
    public ResponseEntity<String> createOrder(@RequestBody OrderRequest request) {
        OrderId orderId = createOrderUseCase.createOrder(request.toCommand());
        return ResponseEntity.ok(orderId.getValue());
    }
}

// Driven Adapter: Pass application requests to external systems
@Repository
public class OrderPersistenceAdapter implements SaveOrderPort {
    private final OrderJpaRepository jpaRepository;

    @Override
    public void save(Order order) {
        OrderEntity entity = OrderMapper.toEntity(order);
        jpaRepository.save(entity);
    }
}

In the code above, OrderController receives HTTP requests and calls CreateOrderUseCase, while OrderPersistenceAdapter implements SaveOrderPort to save to the database. The important point is that the application core is completely unaware of these Adapters.

3. Application Core - “Business Heart”

Inside the hexagon, there is only pure business logic. The Application Core consists of the Application Layer and the Domain Layer. The Application Layer orchestrates business process flow, while the Domain Layer contains the core business rules.

flowchart TB
    subgraph Core["Application Core"]
        subgraph App["Application Layer"]
            AS["Application Service<br>(Use Case impl)"]
        end

        subgraph Domain["Domain Layer"]
            E["Entity"]
            VO["Value Object"]
            DS["Domain Service"]
        end

        AS --> Domain
    end

The Application Core knows nothing about the external world. It does not know what HTTP is, what JPA is, or what Kafka is. It only knows Port interfaces and focuses solely on pure business logic.


Full Structure at a Glance#

Let us see the full picture of how all elements of hexagonal architecture collaborate. Understanding how the external world, Driving Adapters, Inbound Ports, Application Core, Outbound Ports, and Driven Adapters connect reveals the essence of hexagonal architecture.

flowchart TB
    subgraph External["External World"]
        WEB["Web Client"]
        MSG["Kafka"]
        DB[("MySQL")]
        MAIL["Mail Server"]
    end

    subgraph DrivingAdapters["Driving Adapters"]
        WA["OrderController"]
        MA["OrderEventListener"]
    end

    subgraph InPorts["Inbound Ports"]
        COU["CreateOrderUseCase"]
        GOU["GetOrderQuery"]
    end

    subgraph Core["Application Core"]
        OS["OrderService"]
        O["Order"]
        OL["OrderLine"]
    end

    subgraph OutPorts["Outbound Ports"]
        SOP["SaveOrderPort"]
        LOP["LoadOrderPort"]
        SNP["SendNotificationPort"]
    end

    subgraph DrivenAdapters["Driven Adapters"]
        PA["OrderPersistenceAdapter"]
        NA["MailNotificationAdapter"]
    end

    WEB --> WA
    MSG --> MA
    WA --> COU
    MA --> COU
    COU --> OS
    OS --> O
    OS --> SOP
    OS --> SNP
    SOP --> PA
    SNP --> NA
    PA --> DB
    NA --> MAIL

Pay attention to the direction of the arrows in the diagram above. Dependencies always point from outside to inside only. The Application Core knows nothing about the outside and only uses Port interfaces.


Understanding Through Code#

Now let us implement hexagonal architecture with actual code. We will proceed in the order of Port definition, Application Service implementation, and Adapter implementation.

Step 1: Define Ports

First, define the application boundaries with Ports. Inbound Ports define use cases that can be called from outside, and Outbound Ports define external services that the application needs.

// === Inbound Ports ===
// Located in the Application package

// Create order use case
public interface CreateOrderUseCase {
    OrderId execute(CreateOrderCommand command);
}

// Confirm order use case
public interface ConfirmOrderUseCase {
    void execute(OrderId orderId);
}

// Order query (Query)
public interface GetOrderQuery {
    OrderDto execute(OrderId orderId);
}

Inbound Ports clearly define the capabilities the application offers. Each use case has a single business purpose, and the outside world can call the application by looking only at these interfaces.

// === Outbound Ports ===
// Located in the Application package

// Save order
public interface SaveOrderPort {
    void save(Order order);
}

// Load order
public interface LoadOrderPort {
    Order loadById(OrderId id);
    boolean existsById(OrderId id);
}

// Send notification
public interface SendNotificationPort {
    void sendOrderConfirmation(Order order);
}

// Check inventory
public interface CheckInventoryPort {
    boolean isAvailable(ProductId productId, int quantity);
}

Outbound Ports define external services the application needs. All communication with external systems including databases, message queues, and external APIs is done through these Ports.

Step 2: Implement Application Service

The Application Service implements Inbound Ports and uses Outbound Ports. It orchestrates business flow and composes Domain objects to complete use cases.

@Service
@Transactional
public class OrderService implements CreateOrderUseCase, ConfirmOrderUseCase {

    // Depends only on Outbound Ports (interfaces)
    private final SaveOrderPort saveOrderPort;
    private final LoadOrderPort loadOrderPort;
    private final SendNotificationPort notificationPort;
    private final CheckInventoryPort inventoryPort;

    // Constructor injection
    public OrderService(
            SaveOrderPort saveOrderPort,
            LoadOrderPort loadOrderPort,
            SendNotificationPort notificationPort,
            CheckInventoryPort inventoryPort) {
        this.saveOrderPort = saveOrderPort;
        this.loadOrderPort = loadOrderPort;
        this.notificationPort = notificationPort;
        this.inventoryPort = inventoryPort;
    }

    @Override
    public OrderId execute(CreateOrderCommand command) {
        // 1. Check inventory (using Outbound Port)
        for (OrderLineCommand line : command.getLines()) {
            if (!inventoryPort.isAvailable(line.getProductId(), line.getQuantity())) {
                throw new InsufficientInventoryException(line.getProductId());
            }
        }

        // 2. Create order (Domain Logic)
        Order order = Order.create(
            command.getCustomerId(),
            command.toOrderLines()
        );

        // 3. Save (using Outbound Port)
        saveOrderPort.save(order);

        return order.getId();
    }

    @Override
    public void execute(OrderId orderId) {
        // 1. Retrieve order (using Outbound Port)
        Order order = loadOrderPort.loadById(orderId);

        // 2. Confirm order (Domain Logic)
        order.confirm();

        // 3. Save (using Outbound Port)
        saveOrderPort.save(order);

        // 4. Send notification (using Outbound Port)
        notificationPort.sendOrderConfirmation(order);
    }
}

In the code above, OrderService depends only on Port interfaces, not concrete implementations. It has no idea whether SaveOrderPort uses JPA or MongoDB, or whether SendNotificationPort sends email or SMS. This is the core of hexagonal architecture.

Key Point

Application Service only knows Ports (interfaces):

  • SaveOrderPort – does not know if it is JPA or MongoDB
  • SendNotificationPort – does not know if it is email or SMS
  • CheckInventoryPort – does not know if it is an internal DB or external API

That is why this code never needs to change even when external technologies change!

Step 3: Implement Driving Adapters

Driving Adapters receive external requests and call Inbound Ports. For example, a Web Adapter receives HTTP requests, converts them to Command objects, then executes Use Cases.

// === Web Adapter (Driving) ===
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final CreateOrderUseCase createOrderUseCase;
    private final ConfirmOrderUseCase confirmOrderUseCase;
    private final GetOrderQuery getOrderQuery;

    @PostMapping
    public ResponseEntity<OrderIdResponse> createOrder(
            @Valid @RequestBody CreateOrderRequest request) {

        // Convert Request -> Command
        CreateOrderCommand command = request.toCommand();

        // Execute Use Case
        OrderId orderId = createOrderUseCase.execute(command);

        // Create Response
        return ResponseEntity
            .status(HttpStatus.CREATED)
            .body(new OrderIdResponse(orderId.getValue()));
    }

    @PostMapping("/{orderId}/confirm")
    public ResponseEntity<Void> confirmOrder(@PathVariable String orderId) {
        confirmOrderUseCase.execute(OrderId.of(orderId));
        return ResponseEntity.ok().build();
    }

    @GetMapping("/{orderId}")
    public ResponseEntity<OrderResponse> getOrder(@PathVariable String orderId) {
        OrderDto order = getOrderQuery.execute(OrderId.of(orderId));
        return ResponseEntity.ok(OrderResponse.from(order));
    }
}

OrderController only handles the details of the HTTP transport protocol. All it does is receive HTTP requests, convert to Commands, call Use Cases, and convert results to HTTP responses.

// === Message Adapter (Driving) ===
@Component
public class OrderEventListener {

    private final ConfirmOrderUseCase confirmOrderUseCase;

    @KafkaListener(topics = "payment-completed")
    public void onPaymentCompleted(PaymentCompletedEvent event) {
        // Automatically confirm order when payment completes
        confirmOrderUseCase.execute(OrderId.of(event.getOrderId()));
    }
}

The Message Adapter receives Kafka messages and calls Use Cases. The application core does not know about Kafka at all; it is simply called through Inbound Ports.

Step 4: Implement Driven Adapters

Driven Adapters implement Outbound Ports to communicate with external systems. For example, the Persistence Adapter implements SaveOrderPort to handle the technical details of saving to a database.

// === Persistence Adapter (Driven) ===
@Repository
public class OrderPersistenceAdapter implements SaveOrderPort, LoadOrderPort {

    private final OrderJpaRepository jpaRepository;
    private final OrderMapper mapper;

    @Override
    public void save(Order order) {
        OrderEntity entity = mapper.toEntity(order);
        jpaRepository.save(entity);
    }

    @Override
    public Order loadById(OrderId id) {
        return jpaRepository.findById(id.getValue())
            .map(mapper::toDomain)
            .orElseThrow(() -> new OrderNotFoundException(id));
    }

    @Override
    public boolean existsById(OrderId id) {
        return jpaRepository.existsById(id.getValue());
    }
}

OrderPersistenceAdapter accesses the database using JPA. It is responsible for converting Domain objects to JPA Entities and vice versa.

// === Notification Adapter (Driven) ===
@Component
public class EmailNotificationAdapter implements SendNotificationPort {

    private final JavaMailSender mailSender;

    @Override
    public void sendOrderConfirmation(Order order) {
        SimpleMailMessage message = new SimpleMailMessage();
        message.setTo(order.getCustomerEmail());
        message.setSubject("Your order has been confirmed");
        message.setText("Order number: " + order.getId().getValue());

        mailSender.send(message);
    }
}

EmailNotificationAdapter sends emails. If you want to switch to SMS later, simply create a new SmsNotificationAdapter that implements SendNotificationPort. No Application Service code changes are needed.

// === External API Adapter (Driven) ===
@Component
public class InventoryApiAdapter implements CheckInventoryPort {

    private final RestTemplate restTemplate;

    @Override
    public boolean isAvailable(ProductId productId, int quantity) {
        String url = "http://inventory-service/api/products/{id}/stock";

        InventoryResponse response = restTemplate.getForObject(
            url,
            InventoryResponse.class,
            productId.getValue()
        );

        return response.getAvailableQuantity() >= quantity;
    }
}

InventoryApiAdapter communicates with an external inventory management service. The Application Core has no idea whether inventory is in an internal database or fetched from an external API.


Package Structure#

Here is how hexagonal architecture is expressed as a package structure. The adapter package is divided into in and out, the application package contains ports and services, and the domain package holds the pure domain model.

com.example.order/
│
├── adapter/                          # Adapters (external connections)
│   ├── in/                           # Driving Adapters
│   │   ├── web/
│   │   │   ├── OrderController.java
│   │   │   ├── CreateOrderRequest.java
│   │   │   └── OrderResponse.java
│   │   └── message/
│   │       └── OrderEventListener.java
│   │
│   └── out/                          # Driven Adapters
│       ├── persistence/
│       │   ├── OrderPersistenceAdapter.java
│       │   ├── OrderEntity.java
│       │   ├── OrderJpaRepository.java
│       │   └── OrderMapper.java
│       ├── notification/
│       │   └── EmailNotificationAdapter.java
│       └── inventory/
│           └── InventoryApiAdapter.java
│
├── application/                      # Application Core - outer
│   ├── port/
│   │   ├── in/                       # Inbound Ports
│   │   │   ├── CreateOrderUseCase.java
│   │   │   ├── ConfirmOrderUseCase.java
│   │   │   └── GetOrderQuery.java
│   │   └── out/                      # Outbound Ports
│   │       ├── SaveOrderPort.java
│   │       ├── LoadOrderPort.java
│   │       ├── SendNotificationPort.java
│   │       └── CheckInventoryPort.java
│   └── service/
│       └── OrderService.java
│
└── domain/                           # Application Core - inner
    ├── Order.java
    ├── OrderLine.java
    ├── OrderId.java
    ├── OrderStatus.java
    └── Money.java

In this structure, the adapter package is on the outermost layer, while application and domain packages are inside. Dependencies always point from outside to inside only.


Dependency Direction#

The core of hexagonal architecture is the dependency direction. All dependencies point from Adapter to Port, and from Port to Core. The Core depends on nothing.

flowchart TB
    subgraph Adapter["Adapters"]
        DA["Driving Adapters"]
        DRA["Driven Adapters"]
    end

    subgraph Core["Application Core"]
        PORT["Ports"]
        APP["Application Service"]
        DOM["Domain"]
    end

    DA -->|"implements"| PORT
    DRA -->|"implements"| PORT
    APP -->|"uses"| PORT
    APP --> DOM

Core Rules:

Strictly following the dependency rules is the core of hexagonal architecture. First, Adapters implement and depend on Ports. Second, the Application Core knows nothing about Adapters. Third, the Domain depends on nothing and contains only pure business logic.


Benefits of Hexagonal#

Let us examine the key benefits of hexagonal architecture with concrete examples.

1. Testing Becomes Easy

Since you only need to Mock Ports, testing becomes very simple. You can perfectly test business logic without databases or external APIs.

// Just Mock the Ports
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock private SaveOrderPort saveOrderPort;
    @Mock private LoadOrderPort loadOrderPort;
    @Mock private SendNotificationPort notificationPort;
    @Mock private CheckInventoryPort inventoryPort;

    @InjectMocks
    private OrderService orderService;

    @Test
    void order_creation_success() {
        // Given
        when(inventoryPort.isAvailable(any(), anyInt())).thenReturn(true);

        CreateOrderCommand command = new CreateOrderCommand(
            CustomerId.of("customer-1"),
            List.of(new OrderLineCommand(ProductId.of("product-1"), 2))
        );

        // When
        OrderId result = orderService.execute(command);

        // Then
        verify(saveOrderPort).save(any(Order.class));
        assertThat(result).isNotNull();
    }
}

The test above verifies OrderService logic without actual databases or external services. Since Ports are replaced with Mocks, test writing is simple and execution is fast.

2. Technology Replacement Becomes Easy

Since you only need to replace Adapters, you can easily change the technology stack. The Application Core does not need any changes.

flowchart LR
    subgraph Before["Using MySQL"]
        APP1["OrderService"]
        PORT1["SaveOrderPort"]
        MYSQL["MySQL Adapter"]
    end

    subgraph After["Changed to MongoDB"]
        APP2["OrderService<br>(No change!)"]
        PORT2["SaveOrderPort<br>(No change!)"]
        MONGO["MongoDB Adapter<br>(Newly written)"]
    end

    Before -->|"Replace Adapter only"| After

For example, even if you change the database from MySQL to MongoDB, the OrderService code does not need any changes. The SaveOrderPort interface remains the same, and you simply write a new MongoOrderAdapter.

// No Service code changes needed even when switching from MySQL to MongoDB!

// Before: MySQL Adapter
@Repository
public class MySqlOrderAdapter implements SaveOrderPort {
    private final OrderJpaRepository jpaRepository;
    // ...
}

// After: MongoDB Adapter (newly added)
@Repository
public class MongoOrderAdapter implements SaveOrderPort {
    private final OrderMongoRepository mongoRepository;
    // ...
}

3. Adding External Integrations Becomes Easy

If you want to add a new notification channel, you just add an Adapter. Since the Application Core only knows the SendNotificationPort interface, it does not care which Adapter is used.

flowchart LR
    subgraph Before["Email only"]
        SN1["SendNotificationPort"]
        EMAIL["EmailAdapter"]
    end

    subgraph After["Email + SMS + Push"]
        SN2["SendNotificationPort"]
        EMAIL2["EmailAdapter"]
        SMS["SmsAdapter"]
        PUSH["PushAdapter"]
    end

    Before -->|"Add Adapter only"| After

To extend from email-only to SMS and push notifications, just add SmsAdapter and PushAdapter. The Application Service still only calls SendNotificationPort, so no code changes are needed.


Trade-offs#

Hexagonal architecture provides flexibility, but at a cost.

Hexagonal Architecture Trade-offs
AdvantageCost
TestabilityNeed to write Port/Adapter interfaces
Technology replacement flexibilityMore time needed for initial design
External integration isolationIncreased number of files (interface + implementation)
Clear dependency directionEntire team must understand the pattern

When Are the Costs Justified?

The complexity of hexagonal is justified when the possibility of external system changes is high:

  • If there is a possibility of switching the database from MySQL to PostgreSQL -> Worth it
  • If you are certain you will use MySQL forever -> May be excessive abstraction

Comparison with Layered#

Layered and hexagonal architectures are similar but have important differences. Let us first examine the difference in perspective.

Difference in Perspective

Layered emphasizes a vertical top-to-bottom structure, while hexagonal emphasizes a radial inside-outside structure.

flowchart TB
    subgraph Layered["Layered: Top→Bottom"]
        L1["Presentation"]
        L2["Application"]
        L3["Domain"]
        L4["Infrastructure"]
        L1 --> L2 --> L3 --> L4
    end

    subgraph Hex["Hexagonal: Inside↔Outside"]
        H1["Adapter"]
        H2["Port"]
        H3["Core"]
        H1 <--> H2 <--> H3
    end

Detailed Comparison

The table below summarizes the key differences between layered and hexagonal.

PerspectiveLayeredHexagonal
StructureVertical layers (4)Inside/Outside
DependenciesTop to bottomOutside to inside
InfrastructureBottom layerOuter Adapter
EmphasisLayer separationExternal isolation
TestMock neededPort Mock only
Suitable ForSimple projectsProjects with many integrations

Layered is simple and intuitive, but as external system integrations increase, hexagonal becomes more suitable. Hexagonal explicitly separates Ports and Adapters, enabling more flexible responses to external changes.


Common Mistakes#

Let us look at common mistakes when applying hexagonal architecture.

1. Direct Dependency Without Ports

If a Service directly depends on a Repository implementation without a Port, you lose the benefits of hexagonal architecture. You must always depend through interfaces (Ports).

// ❌ Wrong: Service directly depends on Repository implementation
@Service
public class OrderService {
    private final OrderJpaRepository jpaRepository;  // Concrete class!
}

// ✅ Correct: Depend on Port (interface)
@Service
public class OrderService {
    private final SaveOrderPort saveOrderPort;  // interface!
}

If you depend on concrete classes, Service code must be modified when switching JPA to another technology later. If you depend on Ports, you only need to replace Adapters.

2. Business Logic in Adapters

Adapters should only handle conversion. You must not put business logic in Adapters.

// ❌ Wrong: Business logic in Controller
@RestController
public class OrderController {
    @PostMapping
    public ResponseEntity<?> createOrder(@RequestBody OrderRequest request) {
        // Business logic in the Controller!
        if (request.getTotal() > 100000) {
            request.setDiscount(0.1);
        }
        // ...
    }
}

// ✅ Correct: Controller handles only request/response
@RestController
public class OrderController {
    private final CreateOrderUseCase useCase;

    @PostMapping
    public ResponseEntity<?> createOrder(@RequestBody OrderRequest request) {
        OrderId orderId = useCase.execute(request.toCommand());  // Delegate
        return ResponseEntity.ok(new OrderIdResponse(orderId));
    }
}

Controllers should only be responsible for converting HTTP requests to Commands, calling Use Cases, and converting results to HTTP responses.

3. Domain Depends on Ports

The Domain must be completely pure and must not depend even on Ports. The Domain should contain only business logic.

// ❌ Wrong: Entity uses Port
public class Order {
    private final SaveOrderPort saveOrderPort;  // Domain depends on Port!

    public void confirm() {
        this.status = CONFIRMED;
        saveOrderPort.save(this);  // Not allowed!
    }
}

// ✅ Correct: Domain stays pure
public class Order {
    public void confirm() {
        this.status = CONFIRMED;  // State change only
    }
}

// Save in Application Service
@Service
public class OrderService {
    public void confirmOrder(OrderId id) {
        Order order = loadOrderPort.loadById(id);
        order.confirm();
        saveOrderPort.save(order);  // Save in Service
    }
}

Domain Entities only handle state changes, while saving is handled by the Application Service through Ports.


Testing Strategy#

In hexagonal architecture, test each level following the test pyramid.

Strategy by Test Level

flowchart TB
    subgraph Tests["Test Pyramid"]
        E2E["E2E Test<br>(Full Integration)"]
        INT["Integration Test<br>(Adapter)"]
        UNIT["Unit Test<br>(Domain + Service)"]
    end

    E2E --> INT --> UNIT

1. Domain Test (Pure Unit Test)

Since the Domain has no external dependencies, it is the simplest to test. As pure Java objects, test execution is also fast.

class OrderTest {

    @Test
    void order_confirmation_success() {
        // Given
        Order order = Order.create(
            CustomerId.of("c1"),
            List.of(new OrderLine(ProductId.of("p1"), 1, Money.of(10000)))
        );

        // When
        order.confirm();

        // Then
        assertThat(order.getStatus()).isEqualTo(OrderStatus.CONFIRMED);
    }

    @Test
    void already_confirmed_order_cannot_be_confirmed_again() {
        Order order = createConfirmedOrder();

        assertThrows(IllegalStateException.class, () -> order.confirm());
    }
}

2. Application Service Test (Port Mock)

The Application Service is tested by replacing Ports with Mocks. You can verify business logic without actual databases or external services.

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock private SaveOrderPort saveOrderPort;
    @Mock private LoadOrderPort loadOrderPort;
    @Mock private SendNotificationPort notificationPort;
    @Mock private CheckInventoryPort inventoryPort;

    @InjectMocks
    private OrderService orderService;

    @Test
    void order_fails_when_inventory_insufficient() {
        // Given
        when(inventoryPort.isAvailable(any(), anyInt())).thenReturn(false);

        CreateOrderCommand command = createCommand();

        // When & Then
        assertThrows(
            InsufficientInventoryException.class,
            () -> orderService.execute(command)
        );

        verify(saveOrderPort, never()).save(any());
    }
}

3. Adapter Test (Integration Test)

Since Adapters communicate with actual external systems, perform integration tests. Using Spring Boot test tools is convenient.

// Persistence Adapter test
@DataJpaTest
class OrderPersistenceAdapterTest {

    @Autowired
    private OrderJpaRepository jpaRepository;

    private OrderPersistenceAdapter adapter;

    @BeforeEach
    void setUp() {
        adapter = new OrderPersistenceAdapter(jpaRepository, new OrderMapper());
    }

    @Test
    void save_and_load_order() {
        // Given
        Order order = createOrder();

        // When
        adapter.save(order);
        Order found = adapter.loadById(order.getId());

        // Then
        assertThat(found.getId()).isEqualTo(order.getId());
    }
}

// Web Adapter test
@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private CreateOrderUseCase createOrderUseCase;

    @Test
    void create_order_API() throws Exception {
        when(createOrderUseCase.execute(any()))
            .thenReturn(OrderId.of("order-123"));

        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"customerId\":\"c1\",\"items\":[]}"))
            .andExpect(status().isCreated())
            .andExpect(jsonPath("$.orderId").value("order-123"));
    }
}

When Should You Use Hexagonal?#

Hexagonal architecture is not suitable for every project. Choose based on your project characteristics.

Suitable Cases

Hexagonal architecture is particularly useful in the following situations. When there are many external system integrations – for example, projects using multiple databases, REST APIs, and message queues. It also helps clearly define the boundaries of each service in microservice architectures.

When there is a possibility of technology changes – for example, if you might switch databases or change messaging systems later, hexagonal is a good fit. It is also suitable when the team values testing. When integrating with legacy systems, Ports and Adapters allow for clean separation.

Unsuitable Cases

On the other hand, hexagonal may be excessive in the following situations. For small, short-term projects, it can be over-engineering. When the team is not familiar with the pattern, it is better to start with layered architecture.

For simple CRUD applications, it is hard to benefit from hexagonal. For projects with almost no external integrations, it can only add unnecessary complexity.

Best Practice: Which Systems Fit?

System TypeSuitabilityReason
MicroservicesVery suitableClear service boundaries, independent deployment
E-commerce PlatformVery suitableVarious external integrations such as payment/shipping/inventory
Legacy IntegrationSuitableCan isolate legacy with ACL
API GatewaySuitableVarious backend service integrations
IoT SystemsSuitableVarious protocol and device integrations
Simple CRUDUnsuitableLayered is sufficient
Small MVPUnsuitableOver-engineering
No External IntegrationsUnsuitableLayered recommended

Transitioning from Layered to Hexagonal#

You can gradually transition from layered to hexagonal architecture. There is no need to change everything at once.

flowchart LR
    A["Step 1<br>Extract Repository Interface"]
    B["Step 2<br>Apply Port Pattern"]
    C["Step 3<br>Separate Adapters"]

    A --> B --> C

Step 1: Move Repository Interface to Domain

First, move the Repository interface from Infrastructure to Domain.

// Before: Repository in Infrastructure
// After: Interface defined in Domain
public interface OrderRepository {
    void save(Order order);
    Optional<Order> findById(OrderId id);
}

Step 2: Change to Port Naming

Split Repository into SaveOrderPort and LoadOrderPort for more clarity.

// Before: OrderRepository
// After: Split into SaveOrderPort, LoadOrderPort
public interface SaveOrderPort {
    void save(Order order);
}

public interface LoadOrderPort {
    Order loadById(OrderId id);
}

Step 3: Organize Adapter Package Structure

Finally, reorganize the package structure into the hexagonal style.

// Before
com.example.order/
├── controller/
├── service/
├── repository/
└── entity/

// After
com.example.order/
├── adapter/
│   ├── in/web/
│   └── out/persistence/
├── application/
│   ├── port/in/
│   ├── port/out/
│   └── service/
└── domain/

Key Summary#

Hexagonal Architecture Key Summary
ConceptDescriptionExample
PortConnection spec (interface)SaveOrderPort, SendNotificationPort
AdapterConnector (implementation)JpaOrderAdapter, EmailAdapter
CorePure business logicOrderService, Order
DrivingThings that call meController, Kafka Listener
DrivenThings I callRepository impl, API Client

Remember: Dependencies always point from outside -> inside only. The Application Core knows nothing about external technologies.


Next Steps#