Всем привет, сегодня я бы хотел поговорить про 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")
}
}
Давайте чуть-чуть разберемся, что здесь происходит
Поднимает временный PostgreSQL через Testcontainers
PostgreSQLContainer("postgres:15.4").start()Применяет Flyway-миграции в этот контейнер
tasks.named("flywayMigrate") {
url = postgres.jdbcUrl
locations = arrayOf("filesystem:../src/main/resources/db/migration")
}Передаёт креды этого контейнера JOOQ
configuration.jdbc.url = postgres.jdbcUrl
configuration.jdbc.user = postgres.username
configuration.jdbc.password = postgres.passwordJOOQ подключается к этой БД и генерирует Kotlin-классы — создаются POJO/record’ы/DSL для работы с таблицами.
Контейнер останавливается
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 на досуге, на пет проекте или просто по приколу, вы поймете, что он действительно приятный и классный инструмент.
Всем хорошего дня и спасибо за внимание!
Elinkis
Спасибо! Очень интересная информация!