ORM — самая спорная тема в бэкенд-разработке. Hibernate/JPA доминирует в Java-мире уже 20 лет, но растущая популярность подхода «вернуться к SQL» привела к появлению библиотек, которые дают типобезопасный SQL без ORM-магии. В Kotlin-экосистеме два главных кандидата: Exposed от JetBrains и jOOQ от Data Geekery.
На нашем проекте — маркетплейс с 80 таблицами, сложными аналитическими запросами и нагрузкой 5000 QPS — мы протестировали обе библиотеки в production. Вот результаты.
Exposed: лёгкий Kotlin-нативный SQL DSL
Exposed — библиотека от JetBrains. Она предоставляет два API: DSL (pure SQL-like) и DAO (лёгкая ORM). DSL — это типобезопасный конструктор SQL-запросов, DAO — слой сущностей с отношениями. Оба API можно использовать вместе.
// Exposed DSL — типобезопасный SQL
object Products : Table("products") {
val id = long("id").autoIncrement()
val name = varchar("name", 255)
val price = decimal("price", 10, 2)
val categoryId = long("category_id").references(Categories.id)
val createdAt = datetime("created_at")
override val primaryKey = PrimaryKey(id)
}
// Сложный запрос с JOIN, агрегацией и пагинацией
fun findProductsByCategoryWithStats(
categoryId: Long,
page: Int,
pageSize: Int
): List<ProductWithStats> = transaction {
(Products innerJoin Categories innerJoin Reviews)
.select(
Products.id,
Products.name,
Products.price,
Count(Reviews.id).alias("review_count"),
Avg(Reviews.rating).alias("avg_rating")
)
.where { Products.categoryId eq categoryId }
.groupBy(Products.id, Products.name, Products.price)
.orderBy(Avg(Reviews.rating) to SortOrder.DESC)
.limit(pageSize, offset = (page - 1) * pageSize.toLong())
.map { row ->
ProductWithStats(
id = row[Products.id],
name = row[Products.name],
price = row[Products.price],
reviewCount = row[Count(Reviews.id)],
averageRating = row[Avg(Reviews.rating)]?.toDouble() ?: 0.0
)
}
}
Плюсы Exposed: Kotlin-нативный DSL, лёгкий (< 2 МБ), нет зависимости от javax.persistence, transaction-блок — удобная обёртка вокруг JDBC-соединения, DAO API для простых CRUD-операций. Поддержка корутин через newSuspendedTransaction.
Минусы: DSL менее выразительный, чем у jOOQ. Window functions, CTE (Common Table Expressions), рекурсивные запросы — всё это работает, но синтаксис менее элегантный. Нет code generation из существующей БД (приходится писать Table-объекты вручную). Сообщество меньше, чем у jOOQ, и документация иногда отстаёт от реального API.
Exposed DAO: лёгкая ORM
Если вам нужно простое CRUD с отношениями, Exposed DAO избавляет от бойлерплейта:
class Product(id: EntityID<Long>) : LongEntity(id) {
companion object : LongEntityClass<Product>(Products)
var name by Products.name
var price by Products.price
var category by Category referencedOn Products.categoryId
val reviews by Review referrersOn Reviews.productId
}
// CRUD-операции
val product = Product.new {
name = "Mechanical Keyboard"
price = BigDecimal("149.99")
category = Category.findById(1L)!!
}
// Lazy-loading отношений
val reviews = product.reviews.toList() // SELECT * FROM reviews WHERE product_id = ?
DAO использует lazy-loading по умолчанию — это ведёт к проблеме N+1. На продакшене я рекомендую либо использовать DSL для сложных запросов (с явными JOIN), либо отключить lazy-loading и загружать отношения явно через load().
jOOQ: промышленный SQL-мастер
jOOQ — это не ORM. Это генератор типобезопасного Java/Kotlin кода из вашей базы данных. Вы пишете SQL на Kotlin, но компилятор проверяет каждую таблицу, каждый столбец и каждый тип. jOOQ генерирует классы из работающей базы или из миграционных скриптов, и ваш код всегда синхронизирован со схемой.
// jOOQ генерирует классы Products, Categories, Reviews из БД
fun findTopProductsByCategory(
ctx: DSLContext,
categoryId: Long,
limit: Int
): List<ProductWithStats> {
return ctx
.select(
PRODUCTS.ID,
PRODUCTS.NAME,
PRODUCTS.PRICE,
count(REVIEWS.ID).`as`("review_count"),
avg(REVIEWS.RATING).`as`("avg_rating")
)
.from(PRODUCTS)
.join(CATEGORIES).on(PRODUCTS.CATEGORY_ID.eq(CATEGORIES.ID))
.leftJoin(REVIEWS).on(PRODUCTS.ID.eq(REVIEWS.PRODUCT_ID))
.where(PRODUCTS.CATEGORY_ID.eq(categoryId))
.groupBy(PRODUCTS.ID, PRODUCTS.NAME, PRODUCTS.PRICE)
.orderBy(avg(REVIEWS.RATING).desc())
.limit(limit)
.fetch {
ProductWithStats(
id = it.value(PRODUCTS.ID),
name = it.value(PRODUCTS.NAME),
price = it.value(PRODUCTS.PRICE),
reviewCount = it.value("review_count", Int::class.java),
averageRating = it.value("avg_rating", Double::class.java) ?: 0.0
)
}
}
Главное преимущество jOOQ — code generation. Вы не пишете Table-объекты вручную — jOOQ генерирует их из вашей БД. Если столбец переименован или удалён — ваш код не скомпилируется. Это позволяет ловить расхождения схемы БД на этапе компиляции.
jOOQ поддерживает весь SQL: оконные функции, CTE, рекурсивные запросы, lateral joins, хранимые процедуры, JSON-операторы PostgreSQL, полнотекстовый поиск. Если запрос можно написать на SQL — jOOQ его поддерживает. Exposed не покрывает весь SQL-стандарт.
jOOQ + Kotlin Coroutines
jOOQ работает с блокирующими вызовами (через JDBC), но с корутинами это легко оборачивается:
class ProductRepository(
private val dslContext: DSLContext,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) {
suspend fun findById(id: Long): Product? = withContext(dispatcher) {
dslContext
.selectFrom(PRODUCTS)
.where(PRODUCTS.ID.eq(id))
.fetchOne()
?.let { mapToDomain(it) }
}
suspend fun findWithPagination(query: String, page: Int): Page<Product> = withContext(dispatcher) {
val result = dslContext
.selectFrom(PRODUCTS)
.where(PRODUCTS.NAME.containsIgnoreCase(query))
.orderBy(PRODUCTS.CREATED_AT.desc())
.fetchPaged(page, PAGE_SIZE) // Расширение для пагинации
Page(
items = result.items.map { mapToDomain(it) },
total = result.total,
page = page,
totalPages = ceil(result.total.toDouble() / PAGE_SIZE).toInt()
)
}
}
Паттерн прост: все вызовы jOOQ оборачиваем в withContext(Dispatchers.IO). Это не полностью неблокирующий ввод-вывод (_поток блокируется_), но для большинства нагрузок достаточно. Если вам нужен полностью неблокирующий доступ — используйте R2DBC, но это уже другая история.
Производительность
Бенчмарк на аналитическом запросе с 3 JOIN, подзапросом и оконной функцией (таблица products — 500K строк, reviews — 2M строк):
Библиотека | Запрос (avg) | Маппинг (avg) | Общее время (avg)
--------------------|-------------|---------------|-----------------
jOOQ | 28ms | 4ms | 32ms
Exposed DSL | 30ms | 6ms | 36ms
Hibernate/JPA | 45ms | 12ms | 57ms
Raw JDBC + String | 27ms | 3ms | 30ms
Exposed (N+1 DAO) | 28ms | 120ms | 148ms
jOOQ и Exposed DSL показывают похожую производительность — оба генерируют SQL и используют JDBC. Разница минимальна. Hibernate/JPA медленнее из-за управления сущностями, dirty checking и управления сессиями. Raw JDBC — это базовая линия, но без типобезопасности.
Заметьте: Exposed DAO с lazy-loading — катастрофа по производительности (148ms против 36ms). Это проблема N+1 в чистом виде. Если используете DAO — всегда загружайте отношения явно или делайте предвыборку.
Миграции и code generation
Exposed не имеет встроенной системы миграций. Обычно используют Flyway или Liquibase. Table-объекты пишутся вручную, и синхронизация со схемой БД — ваша ответственность. Если добавили столбец в БД, но забыли обновить Table-объект — получите ошибку в рантайме.
jOOQ генерирует код из БД. Типичный workflow:
// build.gradle.kts — jOOQ code generation
jooq {
configuration {
generator {
database {
name = "org.jooq.meta.postgres.PostgresDatabase"
url = "jdbc:postgresql://localhost:5432/mydb"
user = "postgres"
password = "postgres"
}
target {
packageName = "com.example.db"
directory = "src/generated/kotlin"
}
}
}
}
// CI/CD pipeline:
// 1. Flyway мигрирует базу данных
// 2. jOOQ генерирует код на основе мигрированной схемы
// 3. Kotlin компилируется (проверяя, что код соответствует схеме)
// 4. Запускаются тесты
// 5. Деплой
Это железобетонная гарантия: если миграция удалила столбец, ваш код не скомпилируется. Расхождение схемы — невозможно. В production-командах, где несколько разработчиков параллельно меняют схему, это бесценно.
Мой вердикт
Для нового проекта на Kotlin с чистой схемой БД я выбираю jOOQ. Code generation, полный SQL, compile-time safety, лучшая производительность при сложных запросах. Да, он тяжелее Exposed, и DSL менее «Kotlin-стильный», но надёжность на продакшене перевешивает.
Exposed — хороший выбор, если вам нужна лёгкая абстракция для простых CRUD-операций, вы не хотите настраивать code generation, или проект небольшой (< 20 таблиц). Exposed DAO удобен для прототипирования, но на продакшене я рекомендую использовать DSL-часть и явно писать JOIN вместо lazy-loading.
Если вам нужна ORM с отношениями, lazy-loading, каскадами — используйте Hibernate/JPA. Но помните: за удобство вы платите производительностью и контролем над SQL. jOOQ и Exposed дают подход «SQL first», который на продакшене надёжнее.
Пока нет комментариев
Будьте первым, кто оставит комментарий к этой статье