Гексагональная архитектура в Kotlin: чистота и гибкость кода

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Гексагональная архитектура в 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)
}

Всё! Приложение работает. Если завтра мы захотим перейти на реаль

Блоги

Рекомендаций по книгам