Команде нужен небольшой сервис каталога, и привычный Spring тянет за собой вагон абстракций, половина которых не пригодится. Альтернатива из экосистемы Kotlin это Ktor, лёгкий фреймворк от JetBrains, который собирает сервер из коротких функций на корутинах, и Exposed, типизированная обёртка над SQL, выросшая в той же лаборатории. Актуальная версия Ktor 3.6.0 вышла в сентябре 2026 года, Exposed живёт в ветке 1.0.0-beta. Обе библиотеки говорят на одном языке: таблицы описываются объектами, маршруты функциями, конфигурация типизированными свойствами, поэтому код читается как описание задачи. Разберём связку от пустого проекта до эндпоинта, который читает и пишет базу, с рабочим кодом и решениями, которые принимаются на каждом шагу.

Ktor собирает сервер из модулей и плагинов на корутинах

Ktor не навязывает контейнер и глобальный контекст приложения. Сервер это функция, которая получает объект Application и настраивает его: регистрирует маршруты, подключает плагины, читает конфигурацию. Основой служат корутины Kotlin, поэтому обработка тысячи одновременных подключений не требует ни пула потоков на каждый запрос, ни реактивных цепочек, а блокирующие вызовы базы оборачиваются в suspend обёртки.

Движков у Ktor несколько, и у каждого своя ниша:

  1. Netty идёт по умолчанию в генераторе проектов и выдерживает типовую нагрузку веб-сервиса;
  2. CIO написан на чистом Kotlin без внешних зависимостей и экономит место в контейнере;
  3. Jetty пригодится, когда нужны его встроенные модули вроде отдельных обработчиков;
  4. Tomcat выручает в корпоративной среде, где он уже прошёл согласование инфраструктуры.

Проект создаётся тремя путями: веб-генератор на сайте Ktor, отдельный плагин IntelliJ IDEA Ultimate или консольная утилита. Утилита ставится одной командой brew install ktor на macOS или winget install JetBrains.KtorCLI на Windows, после чего ktor new разворачивает заготовку. Системы сборки на выбор Gradle Kotlin, Gradle Groovy, Maven и Amper, а параметры сервера хранят прямо в коде, в YAML или в HOCON.

Минимальный сервер умещается в дюжину строк:

import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.application.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

fun main() {
    embeddedServer(Netty, port = 8080, host = "0.0.0.0") {
        routing {
            get("/health") { call.respondText("ok") }
        }
    }.start(wait = true)
}

Функция embeddedServer получает класс движка, порт, хост и модуль с настройкой. Запуск через EngineMain вместо явного вызова позволяет задать порт и модули в конфигурационном файле, что удобно, когда один артефакт едет в разные окружения. Файл кладут в src/main/resources и выглядит он так:

ktor:
  deployment:
    port: 8080
  application:
    modules:
      - com.example.ApplicationKt.module

Заготовка от генератора приносит каталог с Application.kt, конфигурацию сборки и задачу run, а fat-jar для контейнера собирает плагин Shadow, после чего образ умещается в пару сотен мегабайт.

Маршрутизация описывает вложенные пути без аннотаций

Таблица маршрутов в Ktor это DSL, а не список аннотаций. Блок routing группирует пути по префиксам, и вложенность читается как дерево каталогов. Обработчик получает объект ApplicationCall, из которого достают параметры, запросы и тело, и отвечает методом respond.

routing {
    route("/api/v1") {
        get("/books") {
            call.respond(listAll())
        }
        get("/books/{id}") {
            val id = call.parameters["id"]?.toIntOrNull()
                ?: return@get call.respond(HttpStatusCode.BadRequest)
            call.respond(findBook(id))
        }
        post("/books") {
            val book = call.receive<BookRequest>()
            call.respond(HttpStatusCode.Created, createBook(book))
        }
    }
}

Обратите внимание на разницу: параметры пути достаются через call.parameters, тело запроса через call.receive, а статус ответа задаётся прямо в вызове respond. Проверку идентификатора в BadRequest выносят на входе, чтобы дальше обработчик работал с честным числом. Такой стиль короче аннотаций и не требует рефлексии, что ускоряет старт приложения. Для группы маршрутов с общей проверкой вешают authenticate на блок, и вложенные обработчики получают проверенного пользователя, не повторяя код.

Плагины закрывают сериализацию аутентификацию и сжатие ответов

Функциональность в Ktor подключается плагинами через install. Плагин это функция-расширение Application, которая вешается на конвейер вызовов и может исполняться до или после обработчика. Так работают сжатие ответов, поддержка заголовков безопасности, таймауты, логирование и разбор конфликтов версий.

install(ContentNegotiation) {
    json(Json {
        prettyPrint = false
        ignoreUnknownKeys = true
    })
}

install(Compression) {
    gzip {
        priority = 1.0
    }
}

install(Authentication) {
    basic("admin") {
        validate { creds ->
            if (creds.password == System.getenv("ADMIN_PASS"))
                UserIdPrincipal(creds.name) else null
        }
    }
}

ContentNegotiation учит сервер понимать и отдавать JSON, Serialization плагин генерирует конвертеры из kotlinx.serialization, Compression сжимает крупные ответы, а Authentication вводит проверку. Порядок установки важен: аутентификацию вешают до маршрутов, иначе проверки не сработают. Плагинов в экосистеме десятки, от ведения сессий до метрик, и свой плагин пишется в несколько строк через createApplicationPlugin. Состояние сессий хранит плагин Sessions, подписывающий куку секретом, для публичных API этого достаточно.

Exposed описывает таблицы типизированными Kotlin объектами

Exposed даёт два уровня работы с базой. Верхний, DAO, оперирует сущностями как объектами, нижний, DSL, ближе к SQL и состоит из типизированных функций. В основе обоих лежит описание таблицы объектом Table, где каждый столбец получает тип и ограничения на уровне Kotlin, и опечатка в имени столбца ловится компилятором, а не в три часа ночи на бою.

object Books : Table("books") {
    val id = integer("id").autoIncrement()
    val title = varchar("title", 200)
    val author = varchar("author", 100)
    val price = decimal("price", 10, 2)
    override val primaryKey = PrimaryKey(id)
}

data class Book(
    val id: Int,
    val title: String,
    val author: String,
    val price: BigDecimal
)

Соединение настраивается через JDBC или пул HikariCP, драйверы подходят для PostgreSQL, MySQL, MariaDB, Oracle, SQLite и H2, а отдельный модуль r2dbc открывает реактивный доступ. Создание схемы в dev-окружении делает SchemaUtils.create, при миграциях в проде используют отдельный модуль миграций или внешние инструменты.

Верхний уровень DAO оборачивает таблицы сущностями: класс с идентификатором даёт привычные операции по первичному ключу, связи описываются ссылками между объектами, изменения фиксируются явным сохранением. Уровень выбирают по вкусу команды: DAO короче для типового CRUD, DSL прозрачнее для отчётных запросов и статистики.

Транзакции Exposed держат целостность и корутины под контролем

Любая работа с базой в Exposed идёт внутри транзакции. Блок transaction открывает соединение, выполняет замыкание и подтверждает изменения, а при исключении откатывает всё до исходного состояния. Внутри доступен типизированный DSL: select с условиями, insert со значениями столбцов, update и delete.

fun findBook(id: Int): Book? = transaction {
    Books.selectAll()
        .where { Books.id eq id }
        .singleOrNull()
        ?.toBook()
}

fun createBook(req: BookRequest): Book {
    val id = transaction {
        Books.insert {
            it[title] = req.title
            it[author] = req.author
            it[price] = req.price
        } get Books.id
    }
    return Book(id, req.title, req.author, req.price)
}

Для suspend-мира у Exposed есть обёртка newSuspendedTransaction, которая не блокирует поток обработчика, а использует диспетчер корутин. В Ktor-обработчике, который и так живёт на корутинах, это позволяет писать прямой код без лишних withContext, сохраняя быстрый отклик под нагрузкой.

Выборка для списка книг выглядит так:

fun listBooks(): List<Book> = transaction {
    Books.selectAll().map { row ->
        Book(
            row[Books.id],
            row[Books.title],
            row[Books.author],
            row[Books.price],
        )
    }
}

Каждая строка результата превращается в объект предметной области прямо в транзакции, и наружу уходит уже готовый тип, а не сырые строки.

Приостановляемая версия почти не отличается:

suspend fun findBookSuspend(id: Int): Book? =
    newSuspendedTransaction(Dispatchers.IO) {
        Books.selectAll()
            .where { Books.id eq id }
            .singleOrNull()
            ?.toBook()
    }

Диспетчер указывают явно, чтобы блокирующий вызов JDBC не сел на поток обработчика: корутина честно приостанавливается и освобождает поток для других запросов.

Связка Ktor и Exposed собирается в один запуск приложения

Скелет реального модуля выглядит скромно: подключить базу, создать схему, повесить маршруты. Всё это живёт в одном файле и читается сверху вниз.

fun Application.module() {
    val db = Database.connect(
        url = environment.config.property("db.url").getString(),
        driver = "org.postgresql.Driver",
        user = environment.config.property("db.user").getString(),
        password = environment.config.property("db.pass").getString(),
    )

    install(ContentNegotiation) { json() }

    transaction(db) { SchemaUtils.create(Books) }

    routing {
        route("/api/v1") {
            get("/books") { call.respond(listBooks()) }
            post("/books") {
                val req = call.receive<BookRequest>()
                call.respond(HttpStatusCode.Created, createBook(req))
            }
        }
    }
}

Параметры базы читаются из конфигурации окружения, а не зашиваются в код, поэтому тестовый контур ходит в H2, а прод в PostgreSQL, не меняя ни строчки логики. Пул соединений выносят в HikariCP с явным лимитом:

val pool = HikariConfig().apply {
    jdbcUrl = environment.config.property("db.url").getString()
    maximumPoolSize = 20
    isAutoCommit = false
}
Database.connect(HikariDataSource(pool))

Размер пула подгоняют под потолок базы: двадцать соединений на инстанс это частая отправная точка, а дальше смотрят по ожиданиям в метриках. Проверить собранное можно локальным запуском ./gradlew run, а перед выкаткой гоняют интеграционные тесты через testApplication, который стартует модуль в памяти, кладёт значения в H2 и дёргает маршруты клиентом Ktor.

Производительность и наблюдаемость сервера на практике

Корутины дают связке Ktor и Exposed особый профиль. Тысяча одновременных подключений держится на малом числе потоков, пока обработчики ждут сеть или базу, но стоит один запрос упереться в долгую блокировку, как общий пул съедается. Правило такое: блокирующие вызовы JDBC прячутся в newSuspendedTransaction или отдельный диспетчер, долгие внешние обращения выносятся в отдельные корутины, а чистые вычисления не занимают потоков ввода-вывода. Отдельно ставят лимиты: у запроса к базе их задаёт драйвер, у исходящего HTTP плагин HttpTimeout, иначе зависшая интеграция копит ожидающие корутины.

Наблюдаемость собирается плагинами за вечер. CallLogging пишет время и путь каждого запроса, MicrometerMetrics отдаёт метрики в Prometheus, CallId добавляет сквозной идентификатор для трассировки. Для тревог хватает алертов по времени ответа и доле ошибок, снятых прямо с метрик. Отдельно проверяют пул соединений: HikariCP с дефолтных десяти соединений масштабируют под пропускную способность базы, ориентируясь на её максимальный лимит.

Прагматичный итог по связке. Ktor с Exposed берут, когда нужен быстрый, читаемый сервис без лишнего веса: API на десятки маршрутов, интеграционная прослойка, внутренний инструмент. Если сервис разрастётся, переезд на тяжёлый стек не потребуется, ведь Exposed умеет запросы со связками и агрегатами, а Ktor масштабируется по горизонтали обычными репликами за балансировщиком. Огромный монолит с транзакциями между модулями честнее собирать на тяжёлых фреймворках, но узкий сервис, который должен стартовать за секунды и жить в маленьком контейнере, связка закрывает целиком, а типизация обоих слоёв ловит большинство ошибок ещё на компиляции.