Table of contents
- The Birth of Hexagonal Architecture
- Three Core Concepts
- Dependency Direction — Why Always Inward?
- Use Case Implementation
- Package Structure in Spring
- Testability — Just Swap the Adapters
- Layered vs Hexagonal Comparison
- Common Misconceptions and Cautions
- Key Takeaways
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.
- Inbound Port: The interface used when the outside calls the application. It declares “the functionality this application provides”
- Outbound Port: The interface used when the application calls external systems. It declares “what this application needs”
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.
- REST Controller (HTTP request -> use case invocation)
- CLI (command line arguments -> use case invocation)
- Message Listener (Kafka message -> use case invocation)
- Test code (direct use case invocation)
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.
- JPA Repository implementation (outbound port -> DB storage)
- HTTP Client (outbound port -> external API call)
- Message Publisher (outbound port -> Kafka publishing)
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:
- Domain protection: The domain model is isolated from changes in infrastructure technology
- Adapter replacement: Swapping the JPA adapter for a MongoDB adapter requires no changes to domain or use case code
- Independent testing: Just create a fake implementation (fake, mock) of the outbound port and you can test business logic without infrastructure
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.
| Item | Layered | Hexagonal |
|---|---|---|
| Dependency direction | Top -> Bottom (Business -> Persistence) | Outside -> Inside (Adapter -> Core) |
| Domain model | Mixed with JPA Entity | Pure domain objects |
| DIP application | Optional (usually not applied) | Structurally enforced |
| Testing | Often requires DB | Possible with in-memory implementations |
| File count | Fewer | More (ports, adapters, conversion code) |
| Learning cost | Low | Medium to high |
| Infrastructure replacement | Theoretically possible, practically difficult | Just swap the adapter |
| Suitable situations | CRUD-centric, low complexity | Complex 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
| Concept | Description |
|---|---|
| Application Core | Domain model + use cases. Does not depend on infrastructure |
| Port | Boundary interface between core and outside. Splits into inbound (providing) and outbound (requiring) |
| Adapter | Port implementation. Splits into Driving (outside -> core call) and Driven (core -> outside usage) |
| Dependency direction | Always outside to inside. DIP is structurally applied |
| Key benefit | Domain protection, easy infrastructure replacement, testability |
| Key cost | More 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




Loading comments...