Гексагональная архитектура в Kotlin: когда код дышит свободно
Представьте, что вы строите дом. Можно сразу залить фундамент, возвести стены и воткнуть окна. А можно сначала спроектировать каркас, проложить коммуникации и только потом — заняться отделкой. В мире программирования второй подход называется гексагональной архитектурой (она же Ports & Adapters). И Kotlin с его лаконичностью и мощной системой типов — идеальный язык, чтобы воплотить эту концепцию в жизнь.
Эта статья — не скучный учебник. Это практическое руководство, которое покажет, как Hexagonal архитектура в Kotlin превращает ваш код в гибкую, тестируемую и независимую от внешних сервисов систему. Мы разберем пример с нуля, напишем реальный код и посмотрим, как это работает на практике.
Что такое гексагональная архитектура и зачем она вам?
Алистер Кокберн, придумавший этот термин, сравнивал приложение с шестиугольником (гексагоном). В центре — ваша бизнес-логика. По краям — порты (интерфейсы), через которые она общается с внешним миром. Адаптеры — это реализации этих интерфейсов для конкретных технологий: базы данных, веб-фреймворка, очереди сообщений.
Ключевая идея: бизнес-логика ничего не знает о том, как хранятся данные или какой фреймворк используется. Она просто вызывает методы портов. Хотите заменить PostgreSQL на MongoDB? Просто напишите новый адаптер. Бизнес-логика даже не заметит подмены.
В Kotlin это особенно удобно благодаря интерфейсам с default-реализациями, sealed-классам и функциональному стилю. Давайте перейдем к делу.
Проектируем систему: пример интернет-магазина
Допустим, мы пишем микросервис для управления заказами. В центре — OrderService, который создает заказы и проверяет наличие товаров. Внешний мир — HTTP-контроллер и база данных.
Вот как выглядит структура проекта:
src/
├── domain/ # Ядро (бизнес-логика)
│ ├── model/
│ ├── port/
│ └── service/
├── adapter/
│ ├── input/ # Входящие адаптеры (контроллеры)
│ └── output/ # Исходящие адаптеры (репозитории)
└── Application.kt # Точка входа
Шаг 1. Ядро: модели и порты
Начнем с модели заказа. В Kotlin это будет data class:
// domain/model/Order.kt
data class Order(
val id: String,
val customerId: String,
val items: List<OrderItem>,
val status: OrderStatus
)
enum class OrderStatus {
PENDING, CONFIRMED, SHIPPED, CANCELLED
}
data class OrderItem(
val productId: String,
val quantity: Int
)
Теперь определим порты — интерфейсы, через которые ядро общается с внешним миром. У нас будет два порта: один для получения данных о товарах, второй — для сохранения заказов.
// domain/port/ProductRepository.kt
interface ProductRepository {
fun checkAvailability(productId: String, quantity: Int): Boolean
}
// domain/port/OrderRepository.kt
interface OrderRepository {
fun save(order: Order): Order
fun findById(id: String): Order?
}
Обратите внимание: нигде нет упоминания базы данных, HTTP или фреймворков. Чистая бизнес-логика.
Шаг 2. Бизнес-сервис
Теперь напишем OrderService, который использует эти порты. В Kotlin мы передаем зависимости через конструктор — это называется внедрение зависимостей (DI).
// domain/service/OrderService.kt
class OrderService(
private val productRepository: ProductRepository,
private val orderRepository: OrderRepository
) {
fun createOrder(customerId: String, items: List<OrderItem>): Order {
// Проверяем наличие каждого товара
for (item in items) {
if (!productRepository.checkAvailability(item.productId, item.quantity)) {
throw IllegalStateException("Товар ${item.productId} недоступен")
}
}
val order = Order(
id = generateOrderId(),
customerId = customerId,
items = items,
status = OrderStatus.PENDING
)
return orderRepository.save(order)
}
private fun generateOrderId(): String = "ORD-${System.currentTimeMillis()}"
}
Сервис не знает, как именно проверяется наличие — через REST-запрос к складскому сервису или через локальную БД. Это решают адаптеры.
Шаг 3. Адаптеры: подключаем внешний мир
Напишем два адаптера: один для HTTP (вход), второй для базы данных (выход).
Входящий адаптер (HTTP-контроллер)
Здесь мы используем Ktor — легковесный фреймворк для Kotlin. Но вы можете заменить его на Spring Boot или что угодно — ядро не пострадает.
// adapter/input/OrderController.kt
import io.ktor.server.application.*
import io.ktor.server.routing.*
import io.ktor.server.request.*
import io.ktor.server.response.*
fun Application.configureRouting(orderService: OrderService) {
routing {
post("/orders") {
val request = call.receive<CreateOrderRequest>()
val order = orderService.createOrder(request.customerId, request.items)
call.respond(order)
}
}
}
data class CreateOrderRequest(
val customerId: String,
val items: List<OrderItem>
)
Исходящий адаптер (репозиторий)
Допустим, мы храним заказы в памяти (для простоты примера). В реальном проекте здесь был бы код для MongoDB или PostgreSQL.
// adapter/output/InMemoryOrderRepository.kt
class InMemoryOrderRepository : OrderRepository {
private val orders = mutableMapOf<String, Order>()
override fun save(order: Order): Order {
orders[order.id] = order
return order
}
override fun findById(id: String): Order? = orders[id]
}
// adapter/output/FakeProductRepository.kt
class FakeProductRepository : ProductRepository {
override fun checkAvailability(productId: String, quantity: Int): Boolean {
// Всегда возвращаем true для демонстрации
return true
}
}
Шаг 4. Собираем всё вместе
Главный файл приложения — место, где мы связываем все компоненты. Это называется композиционный корень.
// Application.kt
fun main() {
// Создаем адаптеры
val orderRepository = InMemoryOrderRepository()
val productRepository = FakeProductRepository()
// Создаем сервис с внедренными зависимостями
val orderService = OrderService(productRepository, orderRepository)
// Запускаем Ktor-сервер
embeddedServer(Netty, port = 8080) {
configureRouting(orderService)
}.start(wait = true)
}
Всё! Приложение работает. Если завтра мы захотим перейти на реаль