Загрузка
Все статьи

Exposed vs jOOQ: SQL DSL для Kotlin — сравнение на реальном проекте

Hibernate/JPA — не единственный вариант. В Kotlin-мире есть Exposed от JetBrains и jOOQ от Data Geekery. Обе библиотеки дают типобезопасный SQL без ORM-магии. Сравниваю DX, производительность, миграции и реальный опыт на проекте с 80 таблицами.

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», который на продакшене надёжнее.

Поделиться статьёй

Комментарии

0
0 / 2000

Пока нет комментариев

Будьте первым, кто оставит комментарий к этой статье

Читайте также

Micronaut vs Spring Boot — лёгкий фреймворк для Kotlin-микросервисов

Сравниваю Micronaut и Spring Boot для Kotlin-микросервисов: стартовое время, потребление памяти, холодный старт и реальный опыт миграции одного из сервисов.

12 июня 2026 11 мин

Kotlin: sealed interfaces и when-выражения — паттерн-матчинг как в Rust

Как sealed interfaces из Kotlin 1.5+ вместе с when-выражениями дают мощный паттерн-матчинг, похожий на Rust enum+match, и почему мы перешли на ADT для доменных событий.

11 июня 2026 9 мин

Kotlinx Serialization + Protobuf — быстрая сериализация для микросервисов

Разбираюсь с накладными расходами JSON-сериализации при высоких нагрузках и показываю, как интегрировать Protobuf с kotlinx-serialization в микросервисной архитектуре на Kotlin.

10 июня 2026 10 мин