Все началось с архитектурного тупика. Я занимался бэкенд-частью low-code платформы, для автоматизации внутренних процессов крупных компаний. У платформы была жесткая специфика — обязательный и хардкорный оффлайн-режим. Пользователи — прорабы на удаленных строительных объектах и геологи в тайге, где связь пропадает не на пару минут, а на целые дни.
Приложение при этом должно полноценно жить локально: пользователь забивает данные, меняет статусы сущностей, генерирует документы, прикрепляет фото. А затем, когда появляется сеть, на бэкенд одновременно прилетает лавина накопленных синхронизаций.
Поскольку платформа мультитенантная, микросервисная и крутится в Kubernetes, я быстро уперся в проблему неэффективного использования ресурсов. Тенант отдельной компании может «спать» часами или даже днями, не создавая вообще никакого трафика, а потом пачка юзеров одновременно выходит из оффлайна, и нагрузка на сервис взлетает по экспоненте. Держать под каждый тенант постоянно запущенные и простаивающие поды безумно дорого.
Логичное решение — поднимать сервисы по требованию ближе к моменту реальной нагрузки. В этот момент время запуска перестает быть абстрактной технической метрикой и начинает напрямую влиять на стабильность всей системы. Если Cold Start затягивается, инфраструктура упирается в каскадный отказ: таймауты рвутся, клиенты начинают агрессивные повторные попытки (retry storms), а защитные механизмы вроде Rate Limiting и Circuit Breaker просто начинают веерно отрубать пользователей, чтобы спасти кластер от полной деградации. Быстрый старт стал для меня единственным способом успеть обработать этот всплеск до того, как сработают аварийные предохранители.
Как я уходил от JVM
Я не стал переводить все за один раз, а двигался последовательно, по мере рефакторинга или запуска новых сервисов.
Ktor + Exposed вместо Spring Boot + Hibernate. Типичный сервис на Spring запускался за 25–30 секунд, а ленивое установление соединений до БД в мультитенантной среде только затягивало процесс. Аналогичный сервис на Ktor (JVM) стартовал уже за 2–3 секунды. Мне был критически важен легкий старт и контроль над SQL, а не тяжелая объектная модель. На время этого хватило.
GraalVM Native Image. Идея понятная: оставить JVM-либы, но собрать нативный бинарник. Но я столкнулся с жесткими ограничениями концепции Closed-World — GraalVM на этапе компиляции требует знать весь код приложения и не допускает загрузки неизвестных классов в runtime. Помимо войны с рефлексией для сторонних библиотек, AOT-компиляция лишила меня JIT-оптимизаций на дистанции. Вдобавок в Community-версии GraalVM на момент написания статьи доступен только однопоточный Serial GC, который на тяжелых синхронизациях уводил поды в Stop-The-World.

Добавьте к этому сборки на CI/CD по 10 минут с пожиранием десятков гигабайт RAM — и GraalVM стал выглядеть как тяжелый технологический налог.
Проверяем гипотезу: почему для R&D был выбран Kotlin/Native?
Чтобы проверить саму концепцию Scale-to-zero без боли с GraalVM, мне нужен был чистый исследовательский прототип. И идея пощупать Kotlin/Native в качестве альтернативы выглядела как максимально логичный шаг для R&D.
Хотя Kotlin/Native сейчас находится на этапе активного роста производительности и формирования серверной экосистемы, его фундаментом служит мощный индустриальный стандарт LLVM. Благодаря серьезным инвестициям со стороны JetBrains, технология стремительно эволюционирует, открывая хорошие перспективы для превращения Kotlin/Native в полноценный, независимый и высокоэффективный нативный стек для бэкенд-разработки.
В рамках моего исследования главным критерием был гарантированно моментальный старт здесь и сейчас ради борьбы с Cold Start. И для проверки этой гипотезы Kotlin/Native подходил идеально по двум причинам:
Предсказуемый нативный рантайм. Код через LLVM компилируется напрямую в бинарник под Linux x64. Никаких виртуальных машин, никакого оверхеда и скрытых зависимостей на этапе сборки. Он запускается мгновенно и ведет себя абсолютно предсказуемо, выдавая минимальный memory footprint прямо со старта.
Низкий порог вхождения за счет мультиплатформенности (KMP). Это, пожалуй, главный аргумент. Мне не нужно было учить условный Go или Rust и писать прототипы на чужом языке. Я мог взять существующую доменную логику, вынести ее в shared-модуль и использовать как в нативном прототипе, так и в стандартных JVM-сервисах.
Возможность переключения целевой платформы JVM или K/N — это стратегическая свобода, я могу прямо сейчас писать код и крутить его на привычной, стабильной JVM. А в момент, когда бэкенд-часть компилятора Kotlin/Native окончательно оптимизируют, я смогу просто переключить сборку на Native, если замеры покажут, что так быстрее и выгоднее для инфраструктуры.
Для целей прототипа все складывалось отлично. Сетевой слой и HTTP-клиенты без проблем закрыл мультиплатформенный Ktor. Для интеграции с Apache Kafka тоже удалось быстро найти рабочую обертку. Базовый каркас нативного микросервиса склеивался вполне успешно.
Ровно до тех пор, пока прототип не дошел до работы с реальной базой данных. И вот тут выяснилось, что пока Kotlin/Native активно развивался в сторону тулинга, шаринга кода и сетевых библиотек, серверный слой работы с данными отсутствует.
Великая стена PostgreSQL в Kotlin/Native
На JVM я привык воспринимать, что JDBC есть всегда. Это надежный, проверенный временем и монументальный фундамент, на котором стоят Hibernate, Exposed, jOOQ и вообще все. JDBC полностью берет на себя всю низкоуровневую рутину общения с СУБД. В Kotlin/Native никакого JDBC, разумеется, нет.
Все, что было на тот момент в экосистеме — это pgkn, низкоуровневый драйвер, представляющий собой тонкие биндинги вокруг сишной библиотеки libpq. То есть технически подключиться к PostgreSQL и выполнить запрос можно. Но дальше вы остаетесь один на один с небезопасным кодом, сырыми Result Set, ручным маппингом и одинаковыми простынями DAO-кода.
Вот пример типичного и, честно говоря, избыточного кода, который приходится писать на pgkn:
val list = driver.execute("SELECT * FROM users") {
mapOf(
"id" to it.getLong(0),
"name" to it.getString(1),
"email" to it.getString(2),
"bool" to it.getBoolean(3),
"int" to it.getInt(5),
"timestamp" to it.getLocalDateTime(11)
// ... и так далее для каждого типа данных
)
}
От пары хелперов к Kormium
Самое главное — эксперимент оказался полностью удачным. Нативный прототип микросервиса запускался менее чем за 0.3 секунды и мгновенно включался в работу, полностью ликвидируя проблему Cold Start. Гипотеза подтвердилась, но именно в этот момент я споткнулся о суровую инженерную реальность.
Писать ручной маппинг параметров и результатов для пары таблиц в рамках локального R&D — терпимо. Но этот подход абсолютно не масштабируется, как только прототип нужно превратить в реальную систему с десятками сущностей. Любое изменение схемы данных в PostgreSQL (банальная смена типа или добавление nullable-поля) превращалось в кошмар: приходилось руками идти во все файлы и синхронно переписывать индексы в духе it.getString(X), надеясь не ошибиться с цифрой. Ошибки типизации компилятор игнорировал — все падения собирались строго в рантайме.
Появилась дилемма. С одной стороны, нужно было как-то поддерживать этот код, не превращая разработку в бесконечную рутину. С другой стороны, было не ясно, что если начать писать поверх libpq высокоуровневую абстракцию, не появится ли там оверхед, который сведет на нет всю выгоду от быстрого старта?
Мне очень нравился DSL в Exposed. Их подход показывает важную вещь: API для базы данных на Kotlin должен выглядеть как нативный Kotlin, а не как франкенштейн из чужого языка. Благо, язык дает для этого все карты в руки: делегаты, инфиксные функции, lambdas with receiver. Я решил рискнуть и начать писать по вечерам обертку, которая была бы одновременно удобной и максимально легковесной.
Изначально план был скромным — сделать несколько функций-помощников поверх pgkn, чтобы просто убрать рутину. Но код начал развиваться лавинообразно, потому что одна решенная проблема тут же обнажала следующую. Эволюция шла по вполне логичным шагам:
Борьба со сдвигом индексов (ResultSet). Первым делом я написал extension-функции вроде fun ResultSet.toUser(), чтобы спрятать туда чтение полей. Но стоило добавить колонку в середину таблицы, как все индексы it.getString(2), it.getInt(3) съезжали. Стало понятно: мапить нужно не по хардкодным номерам, а по именам колонок.
Появление объектов-колонок. Чтобы читать по именам и не опечататься в строковых литералах, логично вынести имена в константы. Но раз уж я создаю объект для колонки, почему бы не связать его с типом данных? Так появился базовый класс Column<T>, который знал и имя поля в БД, и то, как его правильно прочитать или закодировать.
DSL для описания таблиц. Раз есть колонки, их нужно где-то группировать. Так родился синглтон object Users : Table(…) (позже сигнатура обрастет типами каталога и сущности — к этому вернемся). На этом этапе сработала магия Kotlin: с помощью делегатов свойств (val id by Column.UUID()) библиотека смогла автоматически перехватывать имена переменных из кода и использовать их как имена колонок в базе, избавляя от необходимости дублировать их строковыми литералами.
Query Builder против конкатенации строк. Имея объекты таблиц и колонок, писать raw SQL в духе «SELECT * FROM » + Users.tableName стало просто противоестественно. Захотелось писать Users.select(). Для этого пришлось написать простейший рендерер SQL, который брал структуру таблицы и генерировал корректную строку запроса, автоматически подставляя плейсхолдеры ($1, $2) вместо значений, чтобы гарантировать защиту от SQL-инъекций.
Типизация условий (предикаты). Самый большой вызов — фильтрация. Писать .where(«age >= 18») — это снова runtime-риски. Используя инфиксные функции, удалось завернуть это в нативный вид: where { Users.age gtEq 18 }. Под капотом выражение gtEq конструировало дерево выражений (AST), которое рендерер превращал в валидный WHERE age >= $1.
Получилась тонкая, строго типизированная абстракция, которая практически не создавала оверхеда в рантайме, но полностью изолировала разработчика от сишных указателей. Из этого R&D-прототипа в итоге и выросла полноценная портируемая ORM — родился Kormium.
И вот что здесь принципиально: все это держится исключительно на штатных средствах языка. В Kormium нет ни одного процессора аннотаций, нет кодогенерации, нет плагина к компилятору и нет маппинга через рантайм-рефлексию. Имена колонок библиотека узнает не магией, а через provideDelegate и property.name — обычные делегаты свойств, которые компилятор Kotlin резолвит на этапе компиляции. Все остальное — это дженерики, инфиксные функции и lambdas with receiver.
Звучит как мелочь, но на дистанции это и есть разница между «живой» и «мертвой» библиотекой. Нет генератора — нечему ломаться при обновлении Kotlin и нечего ждать на сборке: проект собирается обычным kotlinc, без отдельного прохода KSP. IDE видит все как простой код — навигация, автодополнение и рефакторинг-переименование работают из коробки, а в стек-трейсах нет сгенерированных простыней. Поддерживать и читать такую кодовую базу несравнимо дешевле. Отдельный приятный бонус: раз рантайм-рефлексии нет в принципе, библиотека ничего не должна объяснять про себя ни AOT-компилятору Kotlin/Native, ни — если вы все-таки на JVM — GraalVM, с конфигами которого я воевал в самом начале этой истории.
Что такое Kormium сегодня
Kormium — это type-safe ORM и SQL DSL для Kotlin Multiplatform под лицензией Apache 2.0. Таблицы, сущности, типизированные предикаты, транзакции, джойны и агрегации вы описываете один раз на чистом Kotlin, а работает этот код на всех целевых платформах.
Чтобы было предметно — вот тот самый SELECT из примера с pgkn, только теперь без ручного маппинга по индексам. Сначала схема описывается один раз:
object MainCatalog : Catalog // тег базы данных — про каталоги расскажу чуть ниже
object Users : Table<MainCatalog, User>("users", ::User) {
val id by Column.Long().primaryKey()
val name by Column.Text()
val email by Column.Text()
val age by Column.Int()
}
class User : Entity() {
var id by Users.id
var name by Users.name
var email by Users.email
var age by Users.age
}
А дальше чтение и запись — это типобезопасный Kotlin, который возвращает готовые User, а не сырой ResultSet:
transaction {
val all: List<User> = Users.all() // SELECT * FROM users
val adults = Users.find { where { Users.age gtEq 18 } } // ... WHERE age >= $1
Users.insert(User().apply { name = "Alice"; email = "alice@example.com"; age = 30 })
}
Сравните с pgkn-простыней из начала статьи: исчезли индексы колонок, ручной mapOf, риск перепутать тип — и при этом под капотом все так же тонко и без рантайм-рефлексии.
Ядро портируемо, а конкретную СУБД подключает отдельный бэкенд. Поддерживаются три:
-
PostgreSQL — на JVM (JDBC/HikariCP), на Kotlin/Native (напрямую через libpq, без JVM) и асинхронно через R2DBC.
-
MySQL / MariaDB — там же: JVM (JDBC), Native (через libmariadb) и async через R2DBC.
-
SQLite — на JVM, Native, Android и iOS.
И вот тут главное, ради чего все затевалось: насколько мне известно, Kormium — единственный KMP-инструмент с честной поддержкой PostgreSQL прямо на Kotlin/Native. Exposed жестко завязан на JVM, а SQLDelight исторически про мобильный SQLite. С Kormium нативный серверный сервис или CLI-утилита ходят в Postgres вообще без Java-рантайма под ногами.
Проект давно вышел из «наколеночной» стадии. Текущая версия — 0.7.0 (июнь 2026, статус pre-1.0), опубликована на Maven Central (группа io.github.kormium, есть kormium-bom), требует Kotlin 2.4.x и JDK 21+ на JVM.
Функциональность разнесена по модулям, которые подключаются по необходимости. Само ядро (kormium-core) — это чистый Kotlin без зависимостей от драйверов: DSL, модель таблиц и рендеринг SQL. Поверх него живут бэкенды (kormium-postgres, kormium-mysql, kormium-sqlite) и общий async-слой kormium-r2dbc. Из прикладного: kormium-migrate — продакшен-раннер миграций на raw-SQL с валидацией чексумм, advisory-локами на Postgres (защита от параллельного старта подов) и журналом; плюс интеграции kormium-ktor / -ktor-di / -ktor-koin для веба и DI. А kormium-observe — реактивные Flow-запросы в духе Room — я вынес отдельно в фичи ниже: это одна из самых вкусных возможностей библиотеки.
Ключевые фичи, ради которых все делалось
1. Гарантированная безопасность каталогов (Catalog)
В Kormium введена проверка каталогов на уровне фантомных типов. Если у вас в системе несколько баз данных (например, основная транзакционная и кластер кэша), вы можете жестко зафиксировать их в коде:
object MainCatalog : Catalog
object CacheCatalog : Catalog
// основная транзакционная база
object Orders : Table<MainCatalog, Order>("orders", ::Order) {
val id by Column.UUID().primaryKey()
}
class Order : Entity() {
var id by Orders.id
}
// отдельный кластер кэша
object SessionCache : Table<CacheCatalog, Session>("sessions", ::Session) {
val token by Column.Text()
}
class Session : Entity() {
var token by SessionCache.token
}
Если контекст вашей транзакции открыт для MainCatalog, компилятор физически не даст выполнить в ней запрос к таблице из CacheCatalog — это не рантайм-проверка и не линтер, а обычная система типов Kotlin. Перепутать соединение к основной базе и к кэшу, отправить запрос «не в ту БД» при шардинге или мультитенантности — класс ошибок, который в Kormium просто не доезжает до тестов, потому что код с ним не компилируется.
2. Корутины — и на JVM, и на Native
Синхронный и асинхронный API здесь равноправны: есть transaction {} для блокирующего кода и suspendTransaction {} для корутин — не обертка над оберткой, а две честные точки входа. Самое же интересное в том, что suspendTransaction {} — это один и тот же ваш код на всех платформах, а вот механизм под ним Kormium подбирает лучший из доступных.
На JVM драйверы JDBC блокирующие, и наивный оффлоад на фиксированный пул потоков уперся бы в его размер. Поэтому на JDK 21+ Kormium гоняет блокирующие вызовы по виртуальным потокам (Project Loom): корутина, заблокировавшаяся на драйвере, отцепляет несущий поток, блокировка становится дешевой, а реальный потолок параллелизма задает не число потоков, а размер пула соединений.
На Native корутины настоящие — у Kotlin/Native давно многопоточный рантайм, и suspendTransaction это полноценная suspend-функция, а не синхронный вызов в маске. Но ключевое в Postgres-бэкенде: он работает не через блокирующий, а через асинхронный API libpq и собственный socket-реактор (poll на Unix, WSAPoll на Windows). Пока запрос ждет ответа от сети, корутина по-настоящему приостанавливается и возвращает рабочий поток в пул — тот же эффект, что Loom дает на JVM, только без всякой JVM.
А там, где у драйвера нет неблокирующего API (SQLite, MySQL через libmariadb, плюс фоллбэк Postgres на Windows), Kormium честно оффлоадит блокирующий вызов на пул потоков (Dispatchers.Default, поскольку Dispatchers.IO на Native не входит в публичный API). Итог: вы пишете обычный suspend-код, а получаете настоящую неблокирующую работу с сетью там, где это вообще возможно.
3. Честная семантика частичных обновлений
В Kormium четко разделены null и «значение не задано». Сущность хранит только реально присвоенные поля, и если поле не трогали (entity.isSet(column) вернет false), оно просто не попадет в генерируемый INSERT или UPDATE:
// меняем только email — age, name и прочие колонки запрос не тронет
Users.update(User().apply { email = "new@example.com" }) {
where { Users.id eq 42 }
}
// UPDATE users SET email = $1 WHERE id = $2
Это то, что во многих ORM превращается в головную боль: здесь же дефолты на стороне БД, generated-колонки и частичные патчи работают предсказуемо — вы не затираете NULL-ом то, что просто не собирались обновлять.
4. Реактивные запросы из коробки (kormium-observe)
Любой запрос можно подписать как Flow и забыть про ручное обновление данных. Поток сразу отдает текущий результат, а потом сам присылает новый каждый раз, когда в отслеживаемые таблицы прошла запись:
Users.observe(db) { where { Users.age gtEq 18 } }
.collect { adults -> /* UI/кэш сами обновятся на каждый коммит */ }
Это как реактивные запросы в Room, только для Kotlin Multiplatform: экран на Compose или кэш всегда показывают свежие данные, и не нужно вручную дергать базу, чтобы их обновить. А если изменения прилетают пачкой, Kormium не перечитывает запрос на каждое — он сделает это один раз, когда волна схлынет.
5. SQL не прячется
raw SQL остается легальным инструментом (escape hatch) для сложных DDL и специфичных фич СУБД — Kormium не пытается спрятать от вас базу. Философия простая: разработчик должен понимать свои индексы, таблицы и транзакции, а не воевать с протекающей абстракцией. При этом все, что идет через DSL, по-прежнему строго биндится параметрами, так что удобство escape hatch не открывает дверь SQL-инъекциям.
При этом raw SQL не выключает реактивность из фичи №4. Работает это по той же модели, что и в Room: после коммита Kormium смотрит, какие таблицы были изменены, и перезапускает подписанные на них observe-запросы. Если сырой SQL выполнен через объект таблицы (Users.execSql(…)), она помечается измененной сама собой — и подписки обновляются без каких-либо дополнительных действий. Для совсем свободного raw SQL, не привязанного к таблице, от разработчика нужно лишь одно — явно перечислить затронутые таблицы (точно как observedEntities для @RawQuery в Room), и инвалидация снова заработает.
Почему в Kormium нет встроенного кэша?
Это один из частых вопросов, поэтому отвечу прямо: встроенного L2-кэша (в духе second-level cache в Hibernate) в Kormium нет, и это осознанное решение, а не «не успел».
-
Компенсировать нечего. L2-кэш в Hibernate во многом лечит его же модель: identity map, ленивые графы, N+1, повторную материализацию сущностей. Kormium — тонкий явный слой над драйвером, всего этого в нем нет, поэтому и прятать за кэшем нечего.
-
Прозрачный кэш противоречит философии. Он по определению скрывает сетевые round-trip’ы, а весь смысл Kormium — в том, что видно, что происходит с базой. Незаметный слой, который иногда ходит в БД, а иногда нет, ломает эту предсказуемость.
-
Корректный распределенный кэш дорог и опасен. Для целевого multi-instance сценария честный L2 — это Redis плюс стратегия консистентности, инвалидация по первичному ключу и TTL-страховка. Это большая поверхность для тихих stale-after-write багов — худшего класса ошибок, которые не падают, а просто отдают устаревшие данные.
-
Приложение сделает это лучше. Оно знает свой домен: где данные горячие, какой TTL допустим, где можно жить с eventual consistency. Кэш в сервисном слое (Caffeine или Redis, read-through) получается проще, прозрачнее и корректнее универсального.
При этом тема кэша не закрыта — просто вынесена наружу. Kormium дает для этого строительный блок — реактивный observe: поверх него кэш с нужной именно вам политикой собирается в несколько строк. А в ближайшем релизе подъедут и кросс-инстансные уведомления об изменениях (через Postgres LISTEN/NOTIFY, R2DBC или внешний брокер вроде Redis), чтобы инвалидация работала и между подами — рабочий прототип уже лежит в samples/cross-instance-cache.
Что с производительностью?
Я регулярно гоняю собственные бенчмарки (все исходники и runnable-примеры со сквозным логированием, шардингом и Ktor CRUD лежат в samples/).
На JVM: Kormium по производительности полностью сопоставим с Exposed. Более того, в версии 0.5.0 я оптимизировал типизированный биндинг параметров для Postgres, убрав лишний round-trip протокола, что по wire-трейсу практически закрыло разрыв по операциям чтения с Hibernate.
На Native (Linux/macOS): Нативный бэкенд на базе libpq за счет отсутствия оверхеда виртуальной машины на операциях чтения обходит JVM-вариант (демонстрируя порядка ~13k против ~8k ops/s в 8 потоков на тестовом стенде).
Условия замера: PostgreSQL 16 (postgres:16-alpine) с данными на tmpfs и отключенными fsync / synchronous_commit / full_page_writes — меряется оверхед ORM и драйвера, а не латентность диска; 8 рабочих потоков, пул соединений 8, операция findById (SELECT по первичному ключу, autocommit). JVM-цифры — через JMH (2 форка, 5×2s прогрев + 5×2s замер), нативные — оптимизированный release-бинарник одним прогоном (без JMH, потому грубее). Железо: Apple m4 16gb.
Естественно, все цифры относительны — проверяйте на своем железе и своих профилях нагрузки.
Заключение
Kormium вырос из конкретной инженерной боли — Cold Start в мультитенантной среде — и затачивался под сценарии, где быстрый и предсказуемый старт реально решает. Лучше всего он раскрывается, когда вы:
-
идете с Kotlin Multiplatform на сервер и хотите один и тот же типобезопасный слой данных на JVM и Native;
-
строите Serverless- или scale-to-zero-архитектуру, где время старта напрямую влияет на стабильность;
-
пишете легкие CLI-утилиты или нативные сервисы и хотите работать с PostgreSQL или MySQL без JVM под ногами;
-
цените, когда SQL не прячется, а таблицы, каталоги и предикаты проверяет компилятор, а не рантайм.
Отдельный плюс — переключение не обязано быть «все или ничего»: shared-модуль с доменной логикой и DSL Kormium спокойно работает на привычной JVM уже сегодня, а на Native можно перейти ровно тогда, когда это станет выгодно по замерам. Так что попробовать его можно без резких движений в инфраструктуре.
Проект активно развивается: документация, продакшен-гайд и compatibility policy на месте. Если хоть один из пунктов выше — про вас, заглядывайте в репозиторий, пробуйте на своем кейсе и заводите issues: обратная связь на стадии pre-1.0 сейчас особенно ценна.
Почти все, о чем шла речь в статье, есть рабочими проектами в samples: мультикаталог и локальный кэш (sqlite-cache), шардинг (sharding), Ktor CRUD с DI (ktor-di, ktor-koin), async через R2DBC (r2dbc) и кросс-инстансный кэш на уведомлениях (cross-instance-cache, на подходе к релизу). Их можно клонировать и запустить, а не верить на слово.
Подробнее с проектом можно ознакомиться на gihtub: https://github.com/kormium/kormium
ссылка на оригинал статьи https://habr.com/ru/articles/1050588/