Всем привет, сегодня я бы хотел поговорить про 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 на досуге, на пет проекте или просто по приколу, вы поймете, что он действительно приятный и классный инструмент.
Всем хорошего дня и спасибо за внимание!
