Skip to content
ioob.dev
Go back

Software Architecture Part 3 — Hexagonal Architecture

· 8 min read
Software Architecture Series (3/6)
  1. Software Architecture Part 1 — Why Architecture Matters
  2. Software Architecture Part 2 — Layered Architecture
  3. Software Architecture Part 3 — Hexagonal Architecture
  4. Software Architecture Part 4 — Clean Architecture and Onion: Commonalities and Differences Among Concentric Structures
  5. Software Architecture Part 5 — CQRS and Event-Driven: From Read/Write Separation to Event-Based Systems
  6. Software Architecture Part 6 — Modular Monolith: What You Can Try Before Microservices
Table of contents

Table of contents

The Birth of Hexagonal Architecture

In 2005, Alistair Cockburn started from a single concern: if an application’s core logic is coupled to the UI, DB, and external systems, testing and replacement are both difficult. His solution was the Ports & Adapters pattern. The name “hexagonal architecture” comes from drawing the application as a hexagon in diagrams. The hexagonal shape itself has no special meaning — it’s a visual metaphor suggesting that multiple sides can accommodate various ports.

The core idea is simple. Clearly separate the inside of the application (domain + use cases) from the outside (UI, DB, external APIs), and place interfaces called Ports at the boundary. The implementations on the outside are called Adapters, and they plug into the ports.

flowchart TB
    subgraph adapters_left["Driving Adapters"]
        WEB["Web Controller"]
        CLI["CLI"]
        TEST["Test"]
    end

    subgraph core["Application Core"]
        subgraph ports_in["Inbound Ports"]
            UC["UseCase Interface"]
        end
        subgraph app["Application Service"]
            AS["UseCase Implementation"]
        end
        subgraph domain["Domain"]
            DM["Entity, Value Object<br/>Business Rules"]
        end
        subgraph ports_out["Outbound Ports"]
            RP["Repository Interface<br/>External System Interface"]
        end
    end

    subgraph adapters_right["Driven Adapters"]
        DB["JPA Repository"]
        MQ["Message Queue"]
        EXT["External API Client"]
    end

    WEB --> UC
    CLI --> UC
    TEST --> UC
    UC --> AS
    AS --> DM
    AS --> RP
    DB --> RP
    MQ --> RP
    EXT --> RP

    style core fill:#5ca45c,stroke:#2e7d32,color:#fff
    style domain fill:#ffd43b,color:#000
    style ports_in fill:#339af0,color:#fff
    style ports_out fill:#339af0,color:#fff
    style app fill:#51cf66,color:#fff

Looking at the diagram, the direction of the arrows is crucial. The Driving adapters on the left depend toward the application core, and the Driven adapters on the right also depend toward the application core. All dependencies point inward. This is the decisive difference from layered architecture.

Three Core Concepts

Application Core — Domain + Use Cases

The application core consists of two parts. One is the domain model (Entity, Value Object, Domain Service), and the other is the use cases (Application Service).

The domain model is the business rules themselves. Invariants like “an order can only be cancelled in CREATED state” and “stock cannot go negative” belong here. It’s pure code without framework annotations or infrastructure dependencies.

Below is a domain model with business rules only, free of JPA annotations.

class Order private constructor(
    val id: OrderId,
    val productId: ProductId,
    val quantity: Int,
    val totalAmount: Money,
    private var status: OrderStatus
) {

    fun cancel() {
        require(status == OrderStatus.CREATED) {
            "Cannot cancel in state: $status"
        }
        status = OrderStatus.CANCELLED
    }

    fun complete() {
        require(status == OrderStatus.PAID) {
            "Cannot complete in state: $status"
        }
        status = OrderStatus.COMPLETED
    }

    fun currentStatus(): OrderStatus = status

    companion object {
        fun create(productId: ProductId, quantity: Int, unitPrice: Money): Order {
            require(quantity > 0) { "Quantity must be at least 1" }
            return Order(
                id = OrderId.generate(),
                productId = productId,
                quantity = quantity,
                totalAmount = unitPrice * quantity,
                status = OrderStatus.CREATED
            )
        }
    }
}

No @Entity, no @Column. This class works perfectly fine even if JPA disappears. No DB needed to test business rules either.

A use case is the layer that composes domain models to execute a single scenario. The “create an order” use case orchestrates the flow of checking stock, creating the order, and saving it.

Ports — Interfaces at the Boundary

A Port is an interface located at the boundary of the application core. Think of it as a contract the inside defines for communicating with the outside.

Ports divide into two types based on direction.

Let’s look at an inbound port example. The “create order” use case defined as an interface.

interface CreateOrderUseCase {
    fun execute(command: CreateOrderCommand): OrderId
}

data class CreateOrderCommand(
    val productId: ProductId,
    val quantity: Int
)

Outbound ports are abstractions over external dependencies. They declare as interfaces the actions of saving data or calling external APIs.

interface OrderRepository {
    fun save(order: Order)
    fun findById(id: OrderId): Order?
}

interface PaymentPort {
    fun requestPayment(orderId: OrderId, amount: Money): PaymentResult
}

interface NotificationPort {
    fun sendOrderConfirmation(orderId: OrderId, userId: UserId)
}

Ports are owned by the application core. OrderRepository is not about DB technology — it’s the domain declaring “I need the ability to save and retrieve orders.” How and in which DB it’s stored is not the port’s concern.

Adapters — External Implementations

An Adapter is the implementation of a port. The actual code that receives HTTP requests, queries the DB, or calls external APIs lives here.

Adapters also divide into two types based on direction.

Driving (Primary) Adapters are the side that drives the application. Think of them as the starting point of user actions.

Below is a web adapter example. It calls the inbound port (CreateOrderUseCase).

@RestController
@RequestMapping("/api/orders")
class OrderWebAdapter(
    private val createOrderUseCase: CreateOrderUseCase
) {

    @PostMapping
    fun createOrder(@RequestBody request: CreateOrderRequest): ResponseEntity<CreateOrderResponse> {
        val command = CreateOrderCommand(
            productId = ProductId(request.productId),
            quantity = request.quantity
        )
        val orderId = createOrderUseCase.execute(command)
        return ResponseEntity.status(HttpStatus.CREATED)
            .body(CreateOrderResponse(orderId.value))
    }
}

The Controller depends on the CreateOrderUseCase interface. It doesn’t know what the implementation is. Spring simply injects the appropriate implementation.

Driven (Secondary) Adapters are the side the application uses to interact with external systems.

Below is a JPA-based persistence adapter example. It implements the outbound port (OrderRepository).

@Repository
class OrderJpaAdapter(
    private val jpaRepository: OrderJpaRepository
) : OrderRepository {

    override fun save(order: Order) {
        jpaRepository.save(OrderEntity.fromDomain(order))
    }

    override fun findById(id: OrderId): Order? {
        return jpaRepository.findByIdOrNull(id.value)?.toDomain()
    }
}

@Entity
@Table(name = "orders")
class OrderEntity(
    @Id val id: Long,
    @Column val productId: Long,
    @Column val quantity: Int,
    @Column val totalAmount: Long,
    @Column @Enumerated(EnumType.STRING) val status: String
) {
    fun toDomain(): Order = Order.reconstitute(
        id = OrderId(id),
        productId = ProductId(productId),
        quantity = quantity,
        totalAmount = Money(totalAmount),
        status = OrderStatus.valueOf(status)
    )

    companion object {
        fun fromDomain(order: Order): OrderEntity = OrderEntity(
            id = order.id.value,
            productId = order.productId.value,
            quantity = order.quantity,
            totalAmount = order.totalAmount.value,
            status = order.currentStatus().name
        )
    }
}

The @Entity annotation is only on OrderEntity. The domain Order class doesn’t know about JPA. The fromDomain() and toDomain() methods convert between the domain model and the persistence model. This conversion cost is one of hexagonal architecture’s trade-offs.

Dependency Direction — Why Always Inward?

Hexagonal architecture has one dependency rule: All dependencies point from outside to inside. The inside (domain, use cases) doesn’t know the outside (adapters) exists.

This is a direct application of DIP (Dependency Inversion Principle) from SOLID. The content covered in OOP Design Principles Part 3 is expanded to the architectural level here.

flowchart LR
    subgraph outside_left["Outside — Driving"]
        WA["Web Adapter<br/>Controller"]
    end

    subgraph inside["Inside — Application Core"]
        IP["Inbound Port<br/>UseCase Interface"]
        SVC["Application Service<br/>UseCase Implementation"]
        DOM["Domain Model"]
        OP["Outbound Port<br/>Repository Interface"]
    end

    subgraph outside_right["Outside — Driven"]
        PA["Persistence Adapter<br/>JPA Implementation"]
    end

    WA -->|"depends on"| IP
    IP --- SVC
    SVC --> DOM
    SVC -->|"uses"| OP
    PA -->|"implements"| OP

    style inside fill:#5ca45c,stroke:#2e7d32,color:#fff
    style DOM fill:#ffd43b,color:#000

The key part here is the right side. The Persistence Adapter depends toward (implements) the Outbound Port. In layered architecture, Service depended toward Repository, but in hexagonal, the direction is reversed.

Thanks to this inversion, the following becomes possible:

Use Case Implementation

Let’s write an Application Service that implements the inbound port (use case interface).

@Service
class CreateOrderService(
    private val orderRepository: OrderRepository,
    private val productPort: ProductPort,
    private val paymentPort: PaymentPort
) : CreateOrderUseCase {

    @Transactional
    override fun execute(command: CreateOrderCommand): OrderId {
        // 1. Look up product
        val product = productPort.findById(command.productId)
            ?: throw ProductNotFoundException(command.productId)

        // 2. Check stock — domain logic
        product.ensureStockAvailable(command.quantity)

        // 3. Create order — domain model's factory method
        val order = Order.create(
            productId = product.id,
            quantity = command.quantity,
            unitPrice = product.price
        )

        // 4. Save order — using outbound port
        orderRepository.save(order)

        // 5. Request payment — using outbound port
        val paymentResult = paymentPort.requestPayment(order.id, order.totalAmount)
        if (!paymentResult.isSuccess) {
            throw PaymentFailedException(order.id)
        }

        return order.id
    }
}

The Application Service acts as an orchestrator. It delegates business rules themselves to the domain model (Order.create(), product.ensureStockAvailable()), communicates with the outside through ports, and coordinates the overall flow.

Package Structure in Spring

Here’s a commonly used package structure when applying hexagonal architecture to a Spring Boot project.

com.example.shop/
├── order/
│   ├── domain/                          ← Domain model
│   │   ├── Order.kt
│   │   ├── OrderId.kt
│   │   ├── OrderStatus.kt
│   │   └── Money.kt
│   ├── application/                     ← Use cases
│   │   ├── port/
│   │   │   ├── in/                      ← Inbound ports
│   │   │   │   ├── CreateOrderUseCase.kt
│   │   │   │   └── GetOrderQuery.kt
│   │   │   └── out/                     ← Outbound ports
│   │   │       ├── OrderRepository.kt
│   │   │       └── PaymentPort.kt
│   │   └── service/                     ← Use case implementations
│   │       ├── CreateOrderService.kt
│   │       └── GetOrderService.kt
│   └── adapter/                         ← Adapters
│       ├── in/
│       │   └── web/                     ← Driving adapter
│       │       ├── OrderWebAdapter.kt
│       │       ├── CreateOrderRequest.kt
│       │       └── OrderResponse.kt
│       └── out/
│           ├── persistence/             ← Driven adapter
│           │   ├── OrderJpaAdapter.kt
│           │   ├── OrderEntity.kt
│           │   └── OrderJpaRepository.kt
│           └── payment/
│               └── PaymentApiAdapter.kt
└── product/
    ├── domain/
    ├── application/
    └── adapter/

The number of files is definitely more than layered. This is the explicit cost of hexagonal architecture. But each file has a clear responsibility, and the dependency direction is structurally enforced.

Just from the package structure, you can grasp the project’s architecture. domain/ doesn’t know about infrastructure, application/port/ defines boundaries, and adapter/ handles external connections — all evident from directory names.

Testability — Just Swap the Adapters

Where hexagonal architecture’s practical benefit shines most is in testing.

No DB needed to test business logic. Just create in-memory implementations of outbound ports.

class InMemoryOrderRepository : OrderRepository {

    private val store = mutableMapOf<OrderId, Order>()

    override fun save(order: Order) {
        store[order.id] = order
    }

    override fun findById(id: OrderId): Order? {
        return store[id]
    }
}

class StubPaymentPort : PaymentPort {

    var shouldSucceed = true

    override fun requestPayment(orderId: OrderId, amount: Money): PaymentResult {
        return if (shouldSucceed) PaymentResult.success() else PaymentResult.failure("Payment failed")
    }
}

Test the use case with these fake implementations. No Spring Context or DB needed.

class CreateOrderServiceTest {

    private val orderRepository = InMemoryOrderRepository()
    private val productPort = StubProductPort()
    private val paymentPort = StubPaymentPort()

    private val sut = CreateOrderService(orderRepository, productPort, paymentPort)

    @Test
    fun `order is created successfully`() {
        // given
        productPort.register(
            Product(id = ProductId(1), name = "Keyboard", price = Money(50000), stock = 10)
        )

        // when
        val orderId = sut.execute(
            CreateOrderCommand(productId = ProductId(1), quantity = 2)
        )

        // then
        val saved = orderRepository.findById(orderId)
        assertNotNull(saved)
        assertEquals(OrderStatus.CREATED, saved!!.currentStatus())
        assertEquals(Money(100000), saved.totalAmount)
    }

    @Test
    fun `exception thrown when stock is insufficient`() {
        // given
        productPort.register(
            Product(id = ProductId(1), name = "Keyboard", price = Money(50000), stock = 1)
        )

        // when & then
        assertThrows<IllegalArgumentException> {
            sut.execute(
                CreateOrderCommand(productId = ProductId(1), quantity = 5)
            )
        }
    }
}

Tests are fast and stable. No network latency, no DB initialization, no impact from external system failures. If this test breaks, it’s a business logic problem. Not an infrastructure issue.

Layered vs Hexagonal Comparison

Here’s a summary of the key differences between the two architectures.

ItemLayeredHexagonal
Dependency directionTop -> Bottom (Business -> Persistence)Outside -> Inside (Adapter -> Core)
Domain modelMixed with JPA EntityPure domain objects
DIP applicationOptional (usually not applied)Structurally enforced
TestingOften requires DBPossible with in-memory implementations
File countFewerMore (ports, adapters, conversion code)
Learning costLowMedium to high
Infrastructure replacementTheoretically possible, practically difficultJust swap the adapter
Suitable situationsCRUD-centric, low complexityComplex domain, infrastructure independence needed

Transitioning from layered to hexagonal isn’t always the right move. Splitting ports and adapters, separating domain models from JPA Entities, and writing conversion code for a simple CRUD API might be over-engineering.

The moment hexagonal shines is clear: when business rules are complex enough to need expressive domain models, when external systems change frequently, or when unit testing domain logic is critically important.

Common Misconceptions and Cautions

”Does hexagonal mean I don’t need Spring?”

No. Hexagonal is an architecture about the direction of code dependencies, not about excluding frameworks. Spring’s DI container actually helps greatly in implementing hexagonal architecture. Declare port interfaces, annotate adapter implementations with @Repository, and Spring handles the injection.

However, don’t annotate domain models with Spring annotations. @Entity, @Component, @Autowired — these should not appear on domain classes.

”Should I apply hexagonal to every project?”

Not necessarily. Consider the project’s domain complexity, the team’s maturity level, and the maintenance timeline. Applying hexagonal to a 3-month prototype might cost more than it’s worth. Conversely, for a core service that will be operated for 5+ years, the initial investment pays off long-term.

The Conversion Cost Between Domain and Persistence Models

The cost of writing and maintaining Order <-> OrderEntity conversion code is non-trivial. When fields are added, both sides must be updated along with the conversion logic. When this cost exceeds the benefit of domain protection, layered might be more realistic.

In practice, teams sometimes use mapping libraries like MapStruct or keep conversions concise with Kotlin extension functions.

Over-Granular Ports

Creating one inbound port per use case can lead to an explosion of interfaces. CreateOrderUseCase, GetOrderUseCase, CancelOrderUseCase, UpdateOrderUseCase… Depending on project scale, grouping related use cases into a single port is also a reasonable choice. A balance with ISP (Interface Segregation Principle) is needed.

Key Takeaways

ConceptDescription
Application CoreDomain model + use cases. Does not depend on infrastructure
PortBoundary interface between core and outside. Splits into inbound (providing) and outbound (requiring)
AdapterPort implementation. Splits into Driving (outside -> core call) and Driven (core -> outside usage)
Dependency directionAlways outside to inside. DIP is structurally applied
Key benefitDomain protection, easy infrastructure replacement, testability
Key costMore files, conversion code, learning curve

In the next part, we’ll cover clean architecture and onion architecture, which are cousins of hexagonal architecture. All three share the common principle of “dependencies point inward,” but differ in how they divide and name their layers. We’ll compare the commonalities and differences among these concentric structures.

-> Part 4: Clean Architecture and Onion — Commonalities and Differences Among Concentric Structures


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Software Architecture Part 2 — Layered Architecture
Next Post
Software Architecture Part 4 — Clean Architecture and Onion: Commonalities and Differences Among Concentric Structures