Hexagonal Architecture

The idea of Hexagonal Architecture is to put inputs and outputs at the edges of our design. Business logic should not depend on whether we expose a REST or a GraphQL API, and it should not depend on where we get data from — a database, a microservice API exposed via gRPC or REST, or just a simple CSV file.

The pattern allows us to isolate the core logic of our application from outside concerns. Having our core logic isolated means we can easily change data source details without a significant impact or major code rewrites to the codebase.

One of the main advantages we also saw in having an app with clear boundaries is our testing strategy — the majority of our tests can verify our business logic without relying on protocols that can easily change.

PortAdapter 라는 개념을 사용하여 Ports and Adapter Architecture 라고도 한다. 핵심 로직을 주변 Infrastructure 영역으로 부터 분리하는 것이 핵심 이다.

Ports define an abstract API that can be implemented by any suitable technical means (e.g. method invocation in an object-oriented language, remote procedure calls, or Web services)

코드로 구현하는 경우 Port 는 Interface, Adapter 는 구현체로 표현할 수 있음

// Ports
interface OrderCreationPort {
    fun createOrder(orderRequest: OrderRequest): Order
}

// Adapters
class OrderCreationAdapter(
    private val orderService: OrderService
): OrderCreationPort {
    
    @Override
    fun createOrder(orderRequest: OrderRequest): Order {
        return orderService.createOrder(orderRequest)
    }
}

Examples

Domain Layer

data class User(val id: String, val name: String)

interface UserService {
    fun createUser(name: String): User
    fun getUserById(id: String): User?
}

Application Layer

application.port.in:

package com.example.hexagonal.application.port.`in`

import com.example.hexagonal.domain.User

interface CreateUserUseCase {
    fun createUser(name: String): User
}

interface GetUserUseCase {
    fun getUserById(id: String): User?
}

application.port.out:

package com.example.hexagonal.application.port.out

import com.example.hexagonal.domain.User

interface UserPersistencePort {
    fun saveUser(user: User): User
    fun findUserById(id: String): User?
}

Adapter Layer

package com.example.hexagonal.adpater.`in`.web

@RestController
@RequestMapping("/api/users")
class UserController(
    private val createUserUseCase: CreateUserUseCase,
    private val getUserUseCase: GetUserUseCase
) {

    @PostMapping
    fun createUser(@RequestBody request: CreateUserRequest): User {
        return createUserUseCase.createUser(request.name)
    }

    @GetMapping("/{id}")
    fun getUserById(@PathVariable id: String): User? {
        return getUserUseCase.getUserById(id)
    }
}

data class CreateUserRequest(val name: String)

adapter.out.persistence:

package com.example.hexagonal.adapter.out.persistence

import com.example.hexagonal.application.port.out.UserPersistencePort
import com.example.hexagonal.domain.User
import org.springframework.stereotype.Component

@Component
class InMemoryUserRepository : UserPersistencePort {
    private val users = mutableMapOf<String, User>()

    override fun saveUser(user: User): User {
        users[user.id] = user
        return user
    }

    override fun findUserById(id: String): User? {
        return users[id]
    }
}

Repository 외에도 JpaEntity, Mapper 등이 들어갈 수 있다.