Всем привет, сегодня я бы хотел поговорить про JOOQ для чего, зачем и почему и немного сравнить его с Hibernate, Spring data JPA. Долгое время я работал только с Hibernate, Spring data JPA и думал, что лучше них не будет и конкурентов ТОЧНО НЕТ, пока не встретил JOOQ. Сегодня расскажу подробнее что за инструмент, как его лучше приготовить и когда выбрать.

Что такое JOOQ?

Если очень коротко, то JOOQ это библиотека, которая позволяет писать SQL запросы java или kotlin кодом, например:

fun findById(id: Long): UsersRecord? = dslContext.fetchOne(USERS, USERS.ID.eq(id))


Это самый просто и базовый запрос в JOOQ, который достает пользователя по id. Но что такое USERS и как мы его получаем?

Фундаментальная разница в том, что Hibernate, Spring data JPA строит базу на основе объектной модели, а jOOQ — наоборот, генерирует код по реальной схеме БД, что исключает расхождения между кодом и структурой базы, если её не меняют вручную.

JOOQ генерирует несколько объектов по одной таблице, чаще всего это - pojo классы, record, DSL классы.

Pojo классы -
Часто в JOOQ используются для получение объектов, то есть это обычные объекты, сгенерированные по схеме бд

@Suppress("UNCHECKED_CAST") 
data class Users( 
    var id: Long, 
    var name: String?
) : Serializable

Record классы - Здесь уже гораздо интереснее, они используются повсеместно, именно их по дефолту возвращают методы получения объектов, как в моем примере с методом findById , они уже гораздо гибче, они могут без вызова дополнительный атрибутов изменять объект, к примеру

val user = userRepository.findById(chatId)

user?.apply { 
this.name = "Spider-man"
this.update() 
}


Здесь мы без вызова метода save репозитория и любых других действий обновим данные в таблице users. Рекорды, сгенерированные JOOQ, наследуют UpdatableRecordImpl, что как раз и позволяет вызывать различные методы работы с объектом.

DSL классы - Тут все очень понятно, это по сути и есть ваша таблица в бд со всеми ее полями

@Suppress("UNCHECKED_CAST")
open class Users(
    alias: Name,
    path: Table<out Record>?,
    childPath: ForeignKey<out Record, UsersRecord>?,
    parentPath: InverseForeignKey<out Record, UsersRecord>?,
    aliased: Table<UsersRecord>?,
    parameters: Array<Field<*>?>?,
    where: Condition?
): TableImpl<UsersRecord>(
    alias,
    schema,
    path,
    childPath,
    parentPath,
    aliased,
    parameters,
    DSL.comment(""),
    TableOptions.table(),
    where,
) {
companion object { 
 val ID: TableField<UsersRecord, Long?> = createField(DSL.name("id"), SQLDataType.BIGINT.nullable(false), this, "")
 val NAME: TableField<UsersRecord, String?> = createField(DSL.name("name"), SQLDataType.BIGINT.nullable(false), this, "")
}
}

Там дальше реально много, код опущен из-за ненадобности :)

Как сгенерировать JOOQ?

Есть несколько вариантов генерации JOOQ классов

  • JOOQ подключается к БД, читает структуру (таблицы, типы, поля, связи) и генерирует Java/Kotlin-классы. JOOQ по схеме БД — удобно, но небезопасно, потому что для этого генератору нужно подключиться к БД с реальными кредами.

  • JOOQ может прогонять миграции (например, Flyway) на временной базе, затем сгенерировать классы на основе результата миграций. Как по мне это самый лучший из всех вариантов.

  • Из SQL-скрипта

  • Из XML-описания схемы

Мы же будем генерировать классы следующим образом - мы поднимем контейнер через gradle таску, выполним туда миграции и сгенерим JOOQ классы.

Весь gradle.kts будет выглядеть так:

import org.testcontainers.containers.PostgreSQLContainer
import org.testcontainers.utility.DockerImageName
import kotlin.reflect.full.memberProperties
import kotlin.reflect.jvm.isAccessible

plugins {
    kotlin("jvm")
    id("org.flywaydb.flyway") version "9.20.0"
    id("nu.studer.jooq") version "8.2.1"
}

val jooqVersion = "3.19.8"

repositories {
    mavenCentral()
}

dependencies {
    val postgresqlVersion = "42.7.3"

    implementation("org.postgresql:postgresql:$postgresqlVersion")
    jooqGenerator("org.postgresql:postgresql:$postgresqlVersion")
}

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.testcontainers:postgresql:1.18.1")
    }
}

val postgresDelegate = lazy {
    PostgreSQLContainer<Nothing>(
        DockerImageName.parse("postgres:15.4").asCompatibleSubstituteFor("postgres")
    ).also { it.start() }
}

val postgres: PostgreSQLContainer<Nothing> by postgresDelegate


tasks.named<org.flywaydb.gradle.task.FlywayMigrateTask>("flywayMigrate") {
    doFirst {
        url = postgres.jdbcUrl
        user = postgres.username
        password = postgres.password
        defaultSchema = "good_food"
        locations = arrayOf("filesystem:../src/main/resources/db/migration")
    }
    finalizedBy("stopPostgreSQLContainer")
}

tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
    dependsOn("generateJooq")
}

jooq {
    version.set(jooqVersion)
    edition.set(nu.studer.gradle.jooq.JooqEdition.OSS)
    configurations {
        create("main") {
            generateSchemaSourceOnCompilation.set(true)
            jooqConfiguration.apply {
                logging = org.jooq.meta.jaxb.Logging.WARN
                jdbc.apply {
                    driver = "org.postgresql.Driver"
                }
                generator.apply {
                    name = "org.jooq.codegen.KotlinGenerator"
                    database.apply {
                        name = "org.jooq.meta.postgres.PostgresDatabase"
                        schemata.add(
                            org.jooq.meta.jaxb.SchemaMappingType().withInputSchema("good_food")
                        )
                    }
                    strategy.name = "org.jooq.codegen.DefaultGeneratorStrategy"
                    generate.apply {
                        isPojos = true
                        isRecords = true
                        isFluentSetters = true
                        isKotlinNotNullPojoAttributes = true
                    }
                    target.apply {
                        packageName = "dev.test.jooq.goodfood"
                        directory = "${layout.buildDirectory.get()}/generated-sources/jooq"
                        encoding = "UTF-8"
                    }
                }
            }
        }
    }
}

tasks.named<nu.studer.gradle.jooq.JooqGenerate>("generateJooq") {
    dependsOn(tasks.named("flywayMigrate"))

    val jooq = this

    doFirst {
        val fieldName = "jooqConfiguration"
        val field = nu.studer.gradle.jooq.JooqGenerate::class.memberProperties.find { it.name == fieldName }
        field?.let {
            field.isAccessible = true
            val configuration = field.get(jooq) as org.jooq.meta.jaxb.Configuration
            configuration.jdbc.url = postgres.jdbcUrl
            configuration.jdbc.user = postgres.username
            configuration.jdbc.password = postgres.password
        }
    }

    finalizedBy("stopPostgreSQLContainer")
}

tasks.create("stopPostgreSQLContainer") {
    onlyIf {
        postgresDelegate.isInitialized()
    }
    doLast {
        postgres.stop()
        postgres.close()
    }
}

configure<SourceSetContainer> {
    named("main") {
        java.srcDir("${layout.buildDirectory.get()}/generated-sources/jooq")
    }
}

Давайте чуть-чуть разберемся, что здесь происходит

  1. Поднимает временный PostgreSQL через Testcontainers
    PostgreSQLContainer("postgres:15.4").start()

  2. Применяет Flyway-миграции в этот контейнер
    tasks.named("flywayMigrate") {
    url = postgres.jdbcUrl
    locations = arrayOf("filesystem:../src/main/resources/db/migration")
    }

  3. Передаёт креды этого контейнера JOOQ
    configuration.jdbc.url = postgres.jdbcUrl
    configuration.jdbc.user = postgres.username
    configuration.jdbc.password = postgres.password

  4. JOOQ подключается к этой БД и генерирует Kotlin-классы — создаются POJO/record’ы/DSL для работы с таблицами.

  5. Контейнер останавливается
    finalizedBy("stopPostgreSQLContainer")

✅ Плюсы такого подхода

  • Нет реальных кред в коде или CI.

  • Контейнер создаётся динамически и умирает после сборки.

  • Генерация всегда идёт из тех же миграций → схему нельзя забыть обновить.

  • Любой разработчик получит одинаковые JOOQ-классы локально и в CI.

  • JOOQ-классы отражают реальную структуру БД.

  • Никаких внешних зависимостей — всё делается в изолированном контейнере.

  • Всё на уровне Gradle — не нужно держать открытую БД.

❌ Минусы такого подхода

  • Каждый билд запускает Docker-контейнер и накатывает миграции(но таски можно закэшировать).

  • Без Docker/Testcontainers генерация не сработает (например, в CI с ограничениями).

  • Код громоздкий и требует аккуратного обновления версий Testcontainers, Flyway, JOOQ.

После выполнение билда мы получаем все DSL классы для работы с нашими таблицами.

Кто-то сейчас посмотрим на этот громоздкий код, представит, что ему нужно работать почти с нативным SQL который нужно писать кодом и закроет статью, НО, конечно же, у JOOQ есть и куча плюсов, иначе его бы просто не использовали бы, сейчас мы с вами поговорим об этом)

Преимущества JOOQ и как его правильно использовать

JOOQ очень сильный инструмент, позволяющий очень гибко и прозрачно работать с базой данных, он не скрыт под 1000 абстракций и особо под капотом там ничего не происходит, но в этом то и его самый главный плюс. Все супер прозрачно и понятно, не нужно думать о кэшах хибернейта, декартовых произведениях и N+1 проблемах, строить огромные классы Entity и следить за их связями.

Так для кого же подойдет JOOQ и для каких проектов?

  • База данных — центр логики, и важны точные SQL-запросы.

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

  • Приложение — крупное или высоконагруженное, где ORM-подход (как у Hibernate) может стать узким местом.

  • Требуется высокая прозрачность в том, какие запросы реально выполняются.

  • Команда умеет работать с SQL и хочет избежать магии ORM.

Конечно, для маленьких простых CRUD приложений лучше использовать Hibernate, потому что с его помощью можно работать гораздо быстрее, чем с JOOQ и он там просто не нужен. Также, если вы плохо знаете SQL и не хотите тратить на него кучу времени, тоже лучше его не использовать, но при этом это будет сильный буст в знании SQL.

Давайте разберем в пример обычный, самый просто репозиторий с помощью JOOQ

@Repository
class UserRepository(
    private val dslContext: DSLContext
) {

    fun deleteById(id: Long) = dslContext.deleteFrom(USERS).where(USERS.ID.eq(id)).execute()

    fun findById(id: Long): UsersRecord? = dslContext.fetchOne(USERS, USERS.ID.eq(id))

    fun create(users: UsersRecord): UsersRecord? = dslContext.insertInto(USERS)
        .set(users)
        .returning()
        .fetchOne()
}

Все предельно понятно и прозрачно, нет сложных абстракций, а за кодом приятно смотреть и легко читать.

Кто-то правильно заметит, что будет куча Буллер плейт кода и они будут правы, ведь например тяжелые запросы будут выглядеть очень большими и код будет повторятся. Но как по мне это небольшая цена за его плюсы.

Благодаря record классам в jooq очень удобно работать с изменением объектов, не нужны дополнительные методы репозиториев для изменения объектов и вообще репозитории.

Когда же лучше выбрать JOOQ, а когда Hibernate

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

Подходит для:

  • Крупных enterprise-проектов, где SQL-логика — часть бизнеса (аналитика, отчётность, сложные запросы).

  • Проектов с богатой базой, где важно использовать особенности PostgreSQL, Oracle и т.п.

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

Когда выбирать Hibernate, Spring data JPA

Подходит для:

  • Бизнес-приложений со стандартными CRUD-операциями

  • Проектов, где база — это просто хранилище, а не сердце логики.

  • Когда важна скорость разработки и хочется думать в терминах объектов, а не SQL.

Мое мнение:

JOOQ — это выбор, если ты хочешь быть ближе к базе, но при этом не писать сырые SQL руками.

Hibernate, Spring data JPA — это выбор, если тебе важнее удобство и скорость, а не точность и контроль SQL.

Итог

Я работал более 2.5 лет с Hibernate, Spring data JPA и более 1.5 лет с JOOQ, они все по своему крутые и интересные, подходят для разных сценариев и разных областей применения. Я не говорю, что больше никогда не буду использовать Hibernate, Spring data JPA и всегда буду использовать JOOQ, каждый из них по своему хорош.

Сегодня я хотел показать, что есть альтернативы использованию Hibernate, Spring data JPA. Каждый сам решит стоит ли использовать JOOQ в своей практике.

Мое мнение - однозначно да, попробуйте JOOQ на досуге, на пет проекте или просто по приколу, вы поймете, что он действительно приятный и классный инструмент.

Всем хорошего дня и спасибо за внимание!

Комментарии (1)


  1. Elinkis
    10.11.2025 16:30

    Спасибо! Очень интересная информация!