Перейти к основному содержимому

5.09. Работа с базами данных в Kotlin

Разработчику Архитектору

Работа с базами данных в Kotlin

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

  • как Kotlin взаимодействует с файлами как с примитивным способом хранения,
  • как организуется обработка данных в памяти,
  • как осуществляется подключение к внешним системам хранения,
  • какие модели и инструменты применяются при взаимодействии с реляционными и нереляционными базами данных,
  • как реализованы популярные ORM-библиотеки, созданные специально под Kotlin (Exposed, Ktorm), и в чём их отличия от классических решений, таких как Hibernate.

Все рассматриваемые механизмы демонстрируются в контексте реального применения — без упрощений, но с акцентом на понимание принципов работы, а не на копирование готовых решений.


1. Работа с файлами в Kotlin

Файловая система — это один из наиболее базовых уровней хранения данных. Kotlin не предоставляет собственных классов для файлового ввода-вывода, но полностью интегрируется со стандартными библиотеками Java (java.io, java.nio.file). Это позволяет использовать проверенные, кроссплатформенные и высокопроизводительные инструменты без избыточной абстракции.

Базовые операции

Для работы с файлами в Kotlin используются объекты типа java.io.File (устаревший, но простой в использовании) и java.nio.file.Path (современный, более гибкий). В Kotlin эти классы дополнены расширениями, упрощающими чтение и запись:

val file = File("data.txt")
file.writeText("Привет, Kotlin!") // Запись строки
val content = file.readText() // Чтение всего файла как строки
val lines = file.readLines() // Чтение по строкам в список

Для бинарных данных применяются потоковые операции:

val bytes = byteArrayOf(0x01, 0x02, 0x03)
file.writeBytes(bytes)
val readBytes = file.readBytes()

Эти методы являются синхронными и блокирующими — они завершаются только после фактического завершения операции ввода-вывода. В многопоточных приложениях их следует использовать вне основного потока (например, в Dispatchers.IO при использовании Kotlin Coroutines).

Кодировки и локализация

Kotlin по умолчанию использует кодировку UTF-8 в методах writeText, readText, readLines, что соответствует современным стандартам. При необходимости кодировку можно указать явно:

file.writeText("Текст", Charsets.UTF_16)

Это особенно важно при работе с унаследованными системами, где может применяться Windows-1251 или ISO-8859-1.

Контекстные менеджеры и безопасность ресурсов

Хотя readText() и writeText() скрывают управление потоками внутри себя, при ручной работе с InputStream/OutputStream или Reader/Writer необходимо гарантировать корректное освобождение ресурсов. Kotlin предлагает идиоматичное решение — расширение use, реализующее паттерн try-with-resources:

FileInputStream("data.bin").use { input ->
// Работа с input
// input.close() будет вызван автоматически
}

Метод use принимает лямбду, внутри которой можно безопасно использовать ресурс. При выходе из блока, в том числе при возникновении исключения, вызывается close().

Файловая система как хранилище

Несмотря на простоту, файлы редко используются в продакшен-приложениях для хранения структурированных данных по следующим причинам:

  • отсутствие транзакционности;
  • сложность реализации конкурентного доступа;
  • отсутствие индексов и эффективных методов поиска;
  • высокий риск повреждения данных при сбое;
  • необходимость ручной сериализации/десериализации.

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


2. Работа с данными в памяти

Прежде чем данные попадут в базу или файл, они существуют в оперативной памяти. Kotlin предоставляет богатую систему типов и коллекций, позволяющую эффективно моделировать доменные сущности и их связи.

Типы данных и неизменяемость

Kotlin делает акцент на неизменяемости по умолчанию. Объявление как val x = 42, так и val list = listOf(1, 2, 3) создаёт ссылку, которую нельзя переназначить, а в случае коллекций — структуру, которую нельзя модифицировать. Для изменения требуется явно выбрать изменяемую форму:

val immutableList = listOf(1, 2, 3)         // List<Int> — только для чтения
val mutableList = mutableListOf(1, 2, 3) // MutableList<Int> — можно менять

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

Data-классы как основа доменной модели

Одним из ключевых инструментов моделирования данных в Kotlin являются data-классы. Они автоматически генерируют:

  • конструктор с параметрами всех свойств;
  • реализации equals() и hashCode() на основе значений свойств;
  • метод toString() для отладки;
  • функцию copy() для создания изменённых копий (поддержка иммутабельного стиля).

Пример:

data class User(
val id: Long,
val name: String,
val email: String,
val createdAt: Instant
)

copy() позволяет безопасно изменять отдельные поля без побочных эффектов:

val user = User(1, "Анна", "anna@example.com", Instant.now())
val updated = user.copy(email = "new@example.com")

Data-классы идеально подходят для передачи данных между уровнями приложения (DTO), для кэширования, для сериализации и, конечно, для отображения строк таблиц баз данных.

Сериализация

Для сохранения данных в файл или передачи по сети требуется сериализация. Kotlin поддерживает несколько подходов:

  • JSON через kotlinx.serialization — официальный, типобезопасный, поддерживает вложенные структуры, полиморфизм, кастомные сериализаторы;
  • Protobuf, CBOR, Properties — также поддерживаются через соответствующие модули kotlinx.serialization;
  • Java-Serializable — наследуется от Java, но не рекомендуется из-за нестабильности формата и отсутствия контроля версий.

Пример с JSON:

@Serializable
data class User(val id: Long, val name: String)

val json = Json.encodeToString(User(1, "Иван"))
val user = Json.decodeFromString<User>(json)

Сериализация — обязательный этап при работе с внешними системами. От её корректности зависит стабильность взаимодействия между компонентами системы.


3. Работа с базами данных

Под «работой с базой данных» в контексте приложения на Kotlin понимается организация постоянного, структурированного и надёжного хранения данных. На этом уровне возникают следующие задачи:

  • установление соединения с СУБД;
  • выполнение запросов (чтение, запись, модификация);
  • управление транзакциями;
  • преобразование данных между реляционной моделью (таблицы, строки, столбцы) и объектной моделью (классы, свойства, связи);
  • обработка ошибок и восстановление после сбоев;
  • обеспечение безопасности (предотвращение SQL-инъекций);
  • работа с пулами соединений для повышения производительности.

Kotlin, как и Java, опирается на интерфейс java.sql.*, входящий в состав Java SE. Однако его низкоуровневый характер (ручное управление PreparedStatement, ResultSet, Connection) делает код многословным и подверженным ошибкам. Поэтому на практике применяются более высокоуровневые абстракции.


4. Взаимодействие с СУБД

Есть три основных уровня работы с базами данных в Kotlin-экосистеме:

УровеньОписаниеПримеры
Низкоуровневый (JDBC)Прямое использование java.sql.Connection, PreparedStatement, ResultSet. Полный контроль, но высокая трудоёмкость и риск ошибок.java.sql.DriverManager, HikariCP (пул соединений)
SQL-мапперыАвтоматическое сопоставление результатов SQL-запросов с Kotlin-классами. Запросы пишутся вручную, но обработка результата упрощена.JDBI, KotliQuery, MapStruct + кастомные обёртки
ORM (Object-Relational Mapping)Полная абстракция над SQL: объекты в коде отображаются на таблицы, свойства — на столбцы, коллекции — на связи. Запросы могут формулироваться в виде вызовов методов или DSL.Exposed, Ktorm, Hibernate (через hibernate-kotlin или JPA)

Выбор уровня зависит от требований проекта:

  • для микросервисов с простой моделью данных предпочтителен лёгкий ORM или SQL-маппер;
  • для сложных доменных моделей с наследованием, полиморфизмом и кэшированием — полноценный ORM;
  • для высоконагруженных сценариев, где важен контроль над SQL, — ручные запросы с маппингом.

Важно понимать: Kotlin не навязывает никакой конкретный подход. Это позволяет использовать лучший инструмент под задачу — в отличие от некоторых фреймворков, где ORM «вшит» в архитектуру.


5. Низкоуровневая работа: JDBC и его ограничения в Kotlin-контексте

Java Database Connectivity (JDBC) — стандартный API для доступа к реляционным СУБД. Он предоставляет унифицированный интерфейс (DriverManager, Connection, Statement, ResultSet) и реализуется драйверами от поставщиков СУБД: pgjdbc для PostgreSQL, mysql-connector-j для MySQL, mariadb-java-client, sqlite-jdbc и др.

Как работает типичный JDBC-запрос

val conn = DriverManager.getConnection(
"jdbc:postgresql://localhost:5432/mydb",
"user", "password"
)

val stmt = conn.prepareStatement("SELECT id, name FROM users WHERE id = ?")
stmt.setLong(1, 101L)
val rs = stmt.executeQuery()

while (rs.next()) {
val id = rs.getLong("id")
val name = rs.getString("name")
println("$id: $name")
}

rs.close()
stmt.close()
conn.close()

Этот код — корректный Kotlin, но он страдает от нескольких проблем:

  • Многословность: каждая операция требует явного управления ресурсами.
  • Подверженность ошибкам: забытый close(), неправильный индекс параметра (1 вместо 0), непроверенный null.
  • Отсутствие типобезопасности: getString("name") возвращает String?, но компилятор не проверяет, существует ли столбец name в результате.
  • Несовместимость с корутинами: executeQuery() блокирует поток.

Пулы соединений

Открытие нового соединения — дорогая операция. В production-средах используются пулы соединений, которые удерживают заранее инициализированные Connection и выдают их по запросу. Популярные реализации:

  • HikariCP — самый быстрый и лёгкий пул, де-факто стандарт в Kotlin/JVM-мире;
  • Tomcat JDBC Pool, C3P0, DBCP2 — альтернативы, чаще встречаются в legacy-проектах.

Пример инициализации HikariCP:

val config = HikariConfig().apply {
jdbcUrl = "jdbc:postgresql://localhost/mydb"
username = "user"
password = "pass"
maximumPoolSize = 20
connectionTimeout = 3000
}

val dataSource = HikariDataSource(config)

Теперь dataSource.connection возвращает соединение из пула, и его тоже обязательно закрывать (хотя физическое закрытие не происходит — соединение возвращается в пул).

Kotlin-расширения для облегчения JDBC

Комьюнити создало множество расширений, сокращающих шаблонный код:

inline fun <T> Connection.useTransaction(block: Connection.() -> T): T {
return try {
autoCommit = false
val result = block()
commit()
result
} catch (e: Exception) {
rollback()
throw e
} finally {
autoCommit = true
}
}

inline fun <reified T> ResultSet.mapRow(mapper: ResultSet.() -> T): List<T> {
val list = mutableListOf<T>()
while (next()) {
list += mapper()
}
return list
}

Использование:

dataSource.connection.use { conn ->
conn.useTransaction {
val users = prepareStatement("SELECT id, name FROM users")
.executeQuery()
.use { rs -> rs.mapRow { User(getLong("id"), getString("name")!!) } }
// ...
}
}

Такой подход уже ближе к идиоматичному Kotlin: безопасность ресурсов, лаконичность, читаемость. Однако он по-прежнему требует написания SQL вручную и не решает проблему маппинга «таблица ↔ объект» при сложных схемах.


6. Паттерны проектирования для работы с данными

Качественное приложение разделяет ответственности между уровнями. В работе с БД выделяют следующие паттерны:

DAO (Data Access Object)

Изолирует логику доступа к данным от бизнес-логики. Каждый DAO отвечает за одну сущность и предоставляет методы вроде findById, save, delete.

interface UserDao {
fun findById(id: Long): User?
fun save(user: User): User
fun delete(id: Long)
}

Реализация может использовать JDBC, Exposed или любой другой инструмент. Преимущество — простота тестирования (можно подменить реализацией на in-memory базе или моке).

Repository

Более высокоуровневый, чем DAO. Repository работает с агрегатами (группами связанных сущностей), поддерживает критерии поиска (спецификации), кэширование, пагинацию. Часто используется в DDD (Domain-Driven Design).

interface UserRepository : CrudRepository<User, Long> {
fun findByEmail(email: String): User?
fun findActive(page: Int, size: Int): Page<User>
}

Spring Data предоставляет такую абстракцию «из коробки», но её можно реализовать и самостоятельно.

Unit of Work

Паттерн, отслеживающий изменения в объектах в течение одного логического действия (например, одного HTTP-запроса) и применяющий их в рамках одной транзакции. Hibernate реализует его через Session, Exposed — через Transaction.

Преимущество: избегается частичное обновление состояния при ошибке в середине операции.


7. SQL-мапперы: баланс контроля и удобства

SQL-мапперы сохраняют прямой контроль над SQL, но автоматизируют сопоставление результата с Kotlin-объектами.

KotliQuery

Лёгкая библиотека, построенная поверх JDBC, с поддержкой DSL для построения запросов и типобезопасного маппинга.

Пример:

val users = sessionOf(dataSource).use { session ->
session.list("SELECT id, name FROM users WHERE active = ?") {
bind(true)
map { row -> User(row.long("id"), row.string("name")) }
}
}

Особенности:

  • Поддержка NamedParameterJdbcTemplate-подобного синтаксиса (WHERE active = :active);
  • Интеграция с HikariCP;
  • Совместимость с корутинами (асинхронная версия — asyncSessionOf);
  • Минимальный оверхед.

JDBI

Более зрелое решение от разработчиков Dropwizard. Использует аннотации и интерфейсы:

@RegisterRowMapper(UserMapper::class)
interface UserDao {
@SqlQuery("SELECT * FROM users WHERE id = :id")
fun findById(@Bind("id") id: Long): User?

@SqlUpdate("INSERT INTO users (name, email) VALUES (:name, :email)")
@GetGeneratedKeys
fun insert(@BindBean user: User): Long
}

Преимущества JDBI:

  • Чёткое разделение интерфейса и реализации;
  • Поддержка плагинов (например, для Kotlin data-классов);
  • Гибкость: можно комбинировать аннотации и программное построение запросов.

Оба подхода хороши, когда:

  • требуется полный контроль над SQL (оптимизация, оконные функции, CTE);
  • схема БД не меняется часто;
  • команда владеет SQL на продвинутом уровне.

8. ORM: зачем нужна абстракция над SQL?

ORM (Object-Relational Mapping) решает фундаментальную проблему impedance mismatch — несоответствие между реляционной моделью (таблицы, нормализация, внешние ключи) и объектной моделью (иерархии, наследование, полиморфизм, поведение).

Основные функции ORM:

  • декларативное описание сущностей (аннотации или DSL);
  • автоматическая генерация DDL (миграции, схемы);
  • CRUD-операции без написания SQL;
  • ленивая и жадная загрузка связей;
  • кэширование (1st/2nd level);
  • управление транзакциями на уровне бизнес-операций.

Важно: ORM не заменяет знание SQL. Наоборот — эффективное использование ORM требует понимания, какой SQL он генерирует. Антипаттерн «N+1 selects» (множественные запросы при итерации по коллекции связанных сущностей) — прямое следствие непонимания этого.

Hibernate/JPA в Kotlin

Hibernate остаётся самым распространённым ORM в JVM-мире. С Kotlin он совместим, но требует аккуратности:

  • data-классы должны быть open (Hibernate использует прокси-наследование);
  • свойства — var, иначе невозможно изменение через reflection;
  • аннотации из javax.persistence или jakarta.persistence.

Пример:

@Entity
@Table(name = "users")
open class User(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long? = null,

@Column(nullable = false)
var name: String = "",

@ManyToOne(fetch = FetchType.LAZY)
var department: Department? = null
)

Минусы:

  • тяжёлый runtime (инициализация, метаданные);
  • сложный конфигурационный файл (persistence.xml);
  • ограничения Kotlin (необходимость open, var) снижают преимущества языка.

Отсюда возникает потребность в Kotlin-first ORM.


9. Exposed: лёгкий, типобезопасный, Kotlin-нативный ORM

Exposed (разрабатывается JetBrains) — это типобезопасный DSL для SQL, сочетающий два режима работы:

  1. DAO API — классический ORM-стиль с наследованием от Entity;
  2. SQL DSL API — программное построение запросов с компиляционной проверкой типов.

Архитектурные особенности

  • Полностью написан на Kotlin;
  • Не требует reflection — работает через встроенные DSL и extension-функции;
  • Поддерживает транзакции через transaction { };
  • Совместим с корутинами (через suspendedTransactionAsync);
  • Поддерживает основные СУБД: PostgreSQL, MySQL, SQLite, H2, Oracle, SQL Server.

Описание схемы — типобезопасно

Схема описывается как объект, наследующий Table:

object Users : Table() {
val id = long("id").autoIncrement().primaryKey()
val name = varchar("name", length = 50)
val email = varchar("email", length = 100).uniqueIndex()
val createdAt = datetime("created_at").defaultExpression(CurrentDateTime())
}

Компилятор проверяет:

  • наличие столбца при обращении: Users.name;
  • совместимость типов: Users.name eq "Иван" — валидно, Users.id eq "abc" — ошибка на этапе компиляции.

Выполнение запросов (SQL DSL)

val users = Users.selectAll().where { Users.name eq "Иван" }.toList()
val count = Users.slice(Users.id.count()).selectAll().where { Users.createdAt greaterEq oneWeekAgo }.single()[Users.id.count()]

Преимущества DSL:

  • запросы строятся программно — легко параметризовать, переиспользовать части;
  • безопасность от SQL-инъекций (все значения передаются как параметры);
  • подсказки IDE и автодополнение работают на полную мощность.

DAO API (Entity-based)

class User(id: EntityID<Long>) : LongEntity(id) {
companion object : LongEntityClass<User>(Users)

var name by Users.name
var email by Users.email
var createdAt by Users.createdAt
}

// Использование
val user = User.new {
name = "Анна"
email = "anna@example.com"
}
user.refresh() // синхронизация с БД

DAO API удобен для CRUD, но менее гибок при сложных выборках. Рекомендуется использовать SQL DSL как основной инструмент, а DAO — для простых операций.

Производительность и ограничения

  • Exposed генерирует минимальный, читаемый SQL — близкий к написанному вручную;
  • Нет overhead-а на reflection или proxy;
  • Ограничения:
    • Нет встроенной поддержки миграций (используются Flyway/Liquibase отдельно);
    • Нет вторичного кэша;
    • Ленивая загрузка связей не поддерживается — только жадная (join вручную или через with-конструкции).

Exposed идеален для:

  • микросервисов;
  • приложений с умеренной сложностью домена;
  • проектов, где важна прозрачность SQL и контроль над запросами.

10. Ktorm: ORM с функциональным уклоном

Ktorm — это ORM, спроектированный с акцентом на иммутабельность, выразительность DSL и чистоту функционального подхода. В отличие от Exposed, он не предоставляет DAO-стиль с изменяемыми сущностями, а строит всю работу вокруг неизменяемых data-классов и декларативных запросов.

Философия и ключевые принципы

  1. Иммутабельность по умолчанию
    Все сущности — это data class с val, а не var. Изменения осуществляются через copy(), что исключает побочные эффекты и упрощает рассуждение о состоянии.

  2. Типобезопасный SQL DSL без reflection
    Ktorm строит запросы, используя вложенные лямбды и extension-функции. Все операции проверяются на этапе компиляции: типы столбцов, совместимость выражений, наличие полей.

  3. Минимальная зависимость от runtime
    Нет необходимости в байткоде-манипуляциях, прокси или аннотациях. Всё — через Kotlin-код.

  4. Поддержка функциональных паттернов
    Запросы можно компоновать, частично применять, кэшировать как значения, передавать как параметры.

Описание схемы: таблицы и столбцы

Схема определяется через объекты, представляющие таблицы. Каждый столбец — это экземпляр Column<T>:

object Users : Table<Nothing>("users") {
val id = int("id").primaryKey()
val name = varchar("name")
val email = varchar("email")
val createdAt = datetime("created_at")
}

Обратите внимание: Table<Nothing> — так как Ktorm не привязывает таблицу к конкретному классу сущности. Связь устанавливается отдельно.

Сущности

data class User(
val id: Int,
val name: String,
val email: String,
val createdAt: LocalDateTime
)

Нет наследования, нет прокси, нет open/var. Это чистая Kotlin-модель.

Отображение: Row Mapper

Сопоставление строки результата и сущности задаётся явно через rowMapper:

val User.mapper: RowMapper<User> = { row ->
User(
id = row[Users.id],
name = row[Users.name],
email = row[Users.email],
createdAt = row[Users.createdAt]
)
}

Метод row[column] типобезопасен: компилятор знает, что row[Users.id] возвращает Int, а не Any?.

Запросы

Пример выборки:

val users = database.from(Users).select().map { it.mapper }.toList()

Фильтрация и сортировка:

val activeUsers = database
.from(Users)
.select()
.where { Users.createdAt greaterEq oneWeekAgo }
.orderBy(Users.name.asc())
.map { it.mapper }
.toList()

Сложные запросы с JOIN:

val query = database
.from(Users.join(Departments, JoinType.INNER, Users.departmentId eq Departments.id))
.select(Users.name, Departments.name)
.where { Users.active eq true }

val results = query.map { row ->
UserWithDept(
userName = row[Users.name],
deptName = row[Departments.name]
)
}.toList()

Особенность: join() возвращает новую виртуальную таблицу, составленную из нескольких, и к ней можно применять select(), where(), groupBy().

Вставка и обновление

Ktorm не предоставляет save() или update() на уровне сущности — только декларативные операторы:

// INSERT
database.insert(Users) {
set(Users.name, "Мария")
set(Users.email, "maria@example.com")
set(Users.createdAt, LocalDateTime.now())
}

// UPDATE
database.update(Users) {
set(Users.email, "new@example.com")
where { Users.id eq 42 }
}

// DELETE
database.delete(Users) { Users.id eq 42 }

Это ближе к SQL, но с гарантией типобезопасности. Отсутствие «магического» save() снижает риск неявных изменений.

Продвинутые возможности

  • Подзапросы: Users.id inSubQuery { ... };
  • Агрегаты: count(), sum(), avg(), groupBy;
  • Оконные функции: rowNumber().over { partitionBy(Users.departmentId).orderBy(Users.salary.desc()) };
  • Кастомные функции: регистрация собственных SQL-функций;
  • Пакетные операции: insertAndGenerateKey { ... }, batchInsert, batchUpdate.

Производительность и ограничения

  • Плюсы:

    • Отсутствие reflection → быстрая инициализация;
    • Генерируемый SQL предсказуем и оптимизируем;
    • Полная совместимость с корутинами (можно оборачивать withContext(Dispatchers.IO));
    • Лёгкий вес: ~500 KB JAR без зависимостей.
  • Минусы:

    • Нет встроенной поддержки миграций;
    • Нет кэширования;
    • Отсутствие автоматической поддержки связей «один-ко-многим» (нужно вручную писать JOIN или постобработку);
    • Менее зрелая документация по сравнению с Exposed (на 2025 год — активно развивается, но сообщество меньше).

Когда выбирать Ktorm?

  • Когда приоритет — чистота архитектуры, иммутабельность, функциональный стиль;
  • В системах, где доменная модель строится на алгебраических типах данных (sealed classes, Result<T>);
  • При интенсивном использовании корутин и реактивных потоков (Ktorm легко интегрируется с Flow);
  • В проектах, где разработчики предпочитают явное управление запросами, а не «магию» ORM.

11. Сравнение

КритерийExposedKtormHibernate (JPA)
ПарадигмаГибрид: DAO + SQL DSLФункциональный DSLОбъектно-ориентированный ORM
СущностиИзменяемые (var, open)Неизменяемые (val, data class)Изменяемые (var, open, прокси)
ТипобезопасностьВысокая (DSL), частичная (DAO)Очень высокая (всё в DSL)Умеренная (аннотации → runtime-ошибки)
ReflectionНет (DSL), есть (DAO через by)НетДа (интенсивно)
Производительность инициализацииНизкая (мгновенная)НизкаяВысокая (метамодель, прокси)
Сложность SQL-генерацииПростой, прозрачныйПростой, прозрачныйСложный, часто требует оптимизации
Ленивая загрузкаНетНетДа
2nd-level cacheНетНетДа (через Ehcache, Infinispan)
МиграцииНет (сторонние: Flyway)Нет (Flyway/Liquibase)Да (Hibernate SchemaUpdate, но не для production)
Поддержка корутинsuspendedTransactionAsyncЧерез withContext(Dispatchers.IO)Нет (требуется @Transactional(propagation = REQUIRES_NEW) + адаптеры)
ОбучениеЛегко (если знаешь SQL)Средне (нужен функциональный mindset)Сложно (много концепций: Session, Persistence Context, Flush)
СообществоКрупное (JetBrains, Spring Boot интеграции)Растущее (активные maintainer’ы, но меньше adoption)Очень крупное (стандарт де-факто)

Рекомендации по выбору:

  • Стартап / микросервис / PoCExposed (быстро, просто, легко уйти вручную при росте).
  • Финтех / аналитика / ETL / функциональная архитектураKtorm (иммутабельность, композируемость, прозрачность).
  • Корпоративное enterprise-приложение с комплексной моделью (наследование, полиморфизм, аудит)Hibernate (неизбежность legacy-совместимости, поддержка инструментов, зрелые паттерны).

Примечание: в одном проекте можно комбинировать подходы. Например, Exposed/Ktorm для основного домена, а JDBC напрямую — для аналитических запросов с CTE и оконными функциями.


12. Работа с NoSQL в Kotlin

Хотя Kotlin чаще ассоциируется с реляционными БД, он отлично подходит и для NoSQL.

MongoDB

Официальный драйвер mongodb-driver-kotlin (от MongoDB Inc.) предоставляет корутин-совместимый API:

val collection = db.getCollection<Document>("users")

// Вставка
collection.insertOne(
documentOf(
"name" to "Олег",
"email" to "oleg@example.com",
"tags" to listOf("dev", "kotlin")
)
)

// Поиск (типобезопасно через кодеки)
val codecRegistry = fromRegistries(
MongoClientSettings.getDefaultCodecRegistry(),
fromProviders(PojoCodecProvider.builder().automatic(true).build())
)

val userCollection = db.getCollection<User>("users").withCodecRegistry(codecRegistry)
val user = userCollection.find(eq("email", "oleg@example.com")).first()

Можно использовать KMongo — обёртку с улучшенным DSL и поддержкой data-классов «из коробки».

Redis

Для кэширования и быстрых операций — Lettuce или Redisson:

val redis = RedisClient.create("redis://localhost")
val conn = redis.connect()
val sync = conn.sync()

sync.set("user:101:name", "Елена")
val name = sync.get("user:101:name")

Асинхронная версия — conn.async(), реактивная — conn.reactive().

Для типобезопасности — сериализация через kotlinx.serialization:

val json = Json.encodeToString(user)
sync.set("user:101", json)
val restored = Json.decodeFromString<User>(sync.get("user:101")!!)

Гибридные архитектуры

Часто применяется:

  • PostgreSQL — для транзакционных данных (заказы, пользователи);
  • Redis — для сессий, рейт-лимитов, кэша;
  • Elasticsearch/MongoDB — для поиска и аналитики.

Kotlin позволяет унифицировать обработку через абстракции:

interface UserRepository {
suspend fun findById(id: Long): User?
suspend fun save(user: User)
}

class PostgresUserRepository(val db: org.jetbrains.exposed.sql.Database) : UserRepository { ... }
class CachedUserRepository(
private val cache: RedisClient,
private val delegate: UserRepository
) : UserRepository {
override suspend fun findById(id: Long): User? {
val cached = cache.get("user:$id")?.let { Json.decodeFromString<User>(it) }
return cached ?: delegate.findById(id).also { u ->
if (u != null) cache.set("user:$id", Json.encodeToString(u))
}
}
}

13. Практики

Миграции: Flyway и Liquibase

Kotlin не имеет встроенного инструмента миграций, но интегрируется со всеми основными.

Flyway (SQL-ориентированный):

  • миграции — .sql-файлы: V1__create_users.sql, V2__add_email_index.sql;
  • запуск через Flyway.configure().dataSource(...).load().migrate().

Liquibase (XML/YAML/JSON):

  • декларативные изменения: <addColumn tableName="users">...;
  • поддержка генерации diff между состояниями.

Можно писать миграции на Kotlin (через kotlin-script или kotlinc), но это редкость — предпочтителен SQL для прозрачности.

Безопасность

  • SQL-инъекции: у Exposed и Ktorm — невозможны благодаря параметризации. В JDBC — только PreparedStatement, никогда конкатенация строк.
  • Утечки данных: избегайте логирования ResultSet или User(password = "..."). Используйте @JsonIgnore или кастомные toString() в data-классах.
  • Права доступа: учётные записи приложения должны иметь минимальные привилегии (только SELECT/INSERT/UPDATE/DELETE на нужные таблицы, без DROP, CREATE, GRANT).

Тестирование

  1. Unit-тесты — моки репозиториев (Mockk, Mockito-Kotlin).
  2. Интеграционные тесты:
    • Testcontainers — запуск реальной СУБД в Docker-контейнере;
    • H2 in-memory — для простых случаев (но помните: синтаксис H2 ≠ PostgreSQL);
    • Transactional rollback — каждый тест оборачивается в транзакцию, которая откатывается после завершения.

Пример с Testcontainers:

class UserRepositoryTest {
companion object {
@Container
val postgres = PostgreSQLContainer<Nothing>("postgres:15")
.apply { start() }
}

private lateinit var db: Database

@BeforeEach
fun setup() {
db = Database.connect(
url = postgres.jdbcUrl,
driver = "org.postgresql.Driver",
user = postgres.username,
password = postgres.password
)
// Выполнить миграции...
}

@Test
fun `сохранение и выборка`() {
transaction(db) {
User.new { name = "Тест" }
}

val count = transaction(db) {
Users.selectAll().count()
}

assertEquals(1, count)
}
}