Перейти к содержимому
Гайд по jOOQ + Gradle

Гайд по jOOQ + Gradle

27 июня 2026 г.·
Влад Карандашов

Гайд по jOOQ и Gradle
Гайд по jOOQ и Gradle

jOOQ сильно отличается от привычных Java ORM (Hibernate, Spring Data JDBC и тп). Причем не только по синтаксису, но еще и нюансами сборки. Открываешь проект и видишь: битые импорты, IDE красная, приложение не собирается, а документация предлагает сначала настроить code generation на несколько экранов XML или Gradle DSL.

В общем штука не самая очевидная. Но когда выполнишь минимальную настройку — на Hibernate возвращаться уже не хочется. Давайте разберемся в основах и сделаем крепенький пример.

Полный код лежит в учебном проекте jooq-gradle-demo. Это небольшое Spring Boot приложение для анонимных опросов. В нем всего две таблицы, но разбираются все основные нюансы: сборка, CRUD, join, aggregation, returning, insert-select и транзакции.

Обязательно нужен Docker! Testcontainers здесь используется не только для тестов, а прямо во время сборки. Gradle поднимает временный PostgreSQL в контейнере, накатывает Flyway-миграции, запускает jOOQ codegen и сразу удаляет контейнер. Об этом рассказано ниже.

Что такое jOOQ

В целом предполагается, что вы уже имели опыт с каким-нибудь Java ORM: Hibernate (Spring Data JPA), Spring Data Jdbc, ну или хотя бы Jdbi. Так вот каждый из них построен на модели, когда вы описываете ваши таблицы некоторыми Java классами.

Пример entity из hibernate
NewsEventEntity.java
@Entity
@Table(name = "news_table")
public class NewsEventEntity {

  @Id
  @JdbcTypeCode(SqlTypes.UUID)
  @Column(name = "id", nullable = false, columnDefinition = "uuid")
  private UUID id;

  @Column(name = "title", nullable = false, length = 256)
  private String title;

  @Column(name = "message", nullable = false, columnDefinition = "text")
  private String message;
}

Это очень понятная модель. ORM примерно представляет, что находится в БД, может сам составлять запросы и даже сверяться с реальной структурой (ddl-auto: validate). Но такое решение обладает и недостатками:

  • легко ошибиться при описании entity
    • схема может меняться, нужно не забыть все актуализировать
  • часто приходится писать несколько entity на одну таблицу для более сложных запросов
  • сложные запросы составлять сложнее, нужно учитывать как ResultSet мапится на Entity

И вот jOOQ как раз решает эти проблемы. Его удобно воспринимать как типизированный SQL-конструктор.

Смотрите, вот обычный запрос, который хочется выполнить:

select id, question, created_at
from surveys
where id = ?;

Вот как он будет выглядеть в jOOQ:

create.select(SURVEYS.ID, SURVEYS.QUESTION, SURVEYS.CREATED_AT)
        .from(SURVEYS)
        .where(SURVEYS.ID.eq(surveyId))
        .fetchOptional();

Согласитесь, очень похоже на SQL. Причем мы не используем обычные строки для названий таблиц и колонок (это было бы небезопасно и подвержено ихменениям). SURVEYS.ID здесь не строка, а сгенерированный спец-объект. Поэтому несовместимые выражения ловятся еще при компиляции.

jOOQ не пытается спрятать SQL. Мы все равно думаем терминами select, join, group by, returning, подзапросов и оконных функций. Просто используем Java DSL для его описания.

Тут возникает резонный вопрос, а откуда взялся этот самый спец-объект SURVEYS? Вот в Hibernate все описывается разработчиком ручками и это не очень безопасно. На деле идея довольно простая: jOOQ сам читает схему базы, генерирует ее типизированную Java-модель, а потом позволяет писать SQL через эту модель. То есть это можно представить, как будто Hibernate сам генерирует свои Entity во время сборки. Сложность только в том, как это грамотно настроить.

Зачем нужна кодогенерация

Генератор подключается к базе, читает структуру и создает Java-классы для объектов схемы: таблиц, полей, ключей, индексов, records, POJO, DAO, sequences и процедур.

В нашем проекте схема задается Flyway-миграциями:

V1__create_survey_tables.sql
create table surveys
(
    id              uuid primary key       default uuidv7(),
    question        varchar(1024) not null,
    admin_code_hash varchar(255)  not null,
    created_at      timestamptz   not null default now()
);

create table answers
(
    id         uuid primary key       default uuidv7(),
    survey_id  uuid          not null references surveys (id) on delete cascade,
    answer     varchar(4096) not null,
    created_at timestamptz   not null default now()
);

После генерации появляются готовые классы:

build/generated-src/jooq/main
com/karandashov/demo/jooq/generated/
├── DefaultSchema.java
├── Indexes.java
├── Keys.java
├── Tables.java
└── tables/
    ├── Answers.java
    ├── Surveys.java
    ├── daos/
    │   ├── AnswersDao.java
    │   └── SurveysDao.java
    ├── pojos/
    │   ├── Answers.java
    │   └── Surveys.java
    └── records/
        ├── AnswersRecord.java
        └── SurveysRecord.java

Получается можно забыть о несоответствиях:

  • удалили колонку — старое обращение к ней перестало компилироваться;
  • поменяли тип поля — Java-код увидел новый тип;
  • добавили таблицу — получили новое поле в классе;
  • переименовали constraint или index — metadata тоже обновилась.

Сгенерированные классы лежат в build/, не коммитятся и не редактируются руками. Они считаются производным артефактом наравне с .class файлами. Это нужно чтобы у разработчика не было соблазна поправить что-то кривыми ручками.

Окей, как это прикрутить к приложению? Ну очевидно на этап сборки, больше некуда. Когда разработчик нажимает build, должно происходить следующее:

  1. поднимается временная PostgreSQL в docker-контейнере;
  2. применяются Flyway или Liquibase миграции;
  3. отрабатывает jOOQ codegen;
  4. база данных останавливается.

Такой подход полностью автоматизирован. Не нужно делать отдельный стенд, как советуют в глупых туториалах. Единственная загвоздка — чуть дольше будет выполняться сборка. Можно, конечно, направить codegen в локальную dev-базу, но тогда результат зависит от состояния чужого процесса. Кто-то забыл применить миграцию, кто-то руками добавил колонку, CI вообще не видит эту базу — и generated sources уже отличаются. Ходить генератором в production еще хуже.

Maven для таких выкрутасов использовать тяжело. Я пробовал — получилось очень сложно. Поэтому, если захотели использовать jOOQ: лучше сразу пересядьте на Gradle. Он позволяет описывать такие вот сценарии.

Версии jooq, codegen-плагина и generated classes лучше держать одинаковыми. В Spring Boot проекте их удобно выравнивать через BOM, как это сделано в демо.

Ручной вариант: Gradle + Flyway + Testcontainers

Весь описанный выше процесс можно наладить самому. Первая такая реализация сохранилась в этом коммите. Самописная Gradle-задача сама управляет всем жизненным циклом временной базы.

Полная ручная конфигурация codegen
build.gradle.kts
import org.flywaydb.core.Flyway
import org.jooq.codegen.GenerationTool
import org.jooq.meta.jaxb.Configuration as JooqConfiguration
import org.jooq.meta.jaxb.Database
import org.jooq.meta.jaxb.Generate
import org.jooq.meta.jaxb.Generator
import org.jooq.meta.jaxb.Jdbc
import org.jooq.meta.jaxb.Target
import org.testcontainers.postgresql.PostgreSQLContainer
import org.testcontainers.utility.DockerImageName

// Код внутри build.gradle.kts компилируется отдельно от приложения.
// Поэтому Flyway, jOOQ codegen, PostgreSQL driver и Testcontainers
// добавляем в classpath самого build script.
buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        // В демо версии библиотек выравнивает Spring Boot BOM.
        classpath(platform(libs.spring.boot.dependencies))
        classpath(libs.flyway.core)
        classpath(libs.flyway.database.postgresql)
        classpath(libs.jooq.codegen)
        classpath(libs.postgresql)
        classpath(libs.testcontainers.postgresql)
    }
}

plugins {
    java
    alias(libs.plugins.spring.boot)
    alias(libs.plugins.spring.dependency.management)
}

repositories {
    mavenCentral()
}

// Каталог generated sources держим внутри build:
// его можно удалить и в любой момент получить заново.
val generatedJooqDir = layout.buildDirectory.dir("generated-src/jooq/main")
val postgresImage = "postgres:${libs.versions.postgres.get()}"

// Сообщаем Java compiler, где искать сгенерированные классы.
sourceSets {
    main {
        java {
            srcDir(generatedJooqDir)
        }
    }
}

dependencies {
    implementation(libs.spring.boot.starter.flyway)
    implementation(libs.spring.boot.starter.jooq)
    runtimeOnly(libs.postgresql)
}

val generateJooq by tasks.registering {
    group = "jooq"
    description = "Generates jOOQ sources from a migrated PostgreSQL Testcontainer"

    val migrationsDir =
        layout.projectDirectory.dir("src/main/resources/db/migration")
    val outputDir = generatedJooqDir

    // Если миграции не менялись и output существует,
    // Gradle может не запускать задачу и не поднимать контейнер.
    inputs.files(fileTree(migrationsDir))
    outputs.dir(outputDir)

    doLast {
        // use гарантирует остановку контейнера после codegen,
        // в том числе при исключении.
        PostgreSQLContainer(DockerImageName.parse(postgresImage)).use { postgres ->
            postgres.start()

            // Сначала получаем реальную схему из Flyway-миграций.
            Flyway.configure()
                .dataSource(
                    postgres.jdbcUrl,
                    postgres.username,
                    postgres.password
                )
                .locations("filesystem:${migrationsDir.asFile.absolutePath}")
                .schemas("public")
                .defaultSchema("public")
                .validateMigrationNaming(true)
                .cleanDisabled(true)
                // В демо есть CREATE INDEX CONCURRENTLY.
                // Ему нельзя работать внутри transaction block.
                .configuration(
                    mapOf("flyway.postgresql.transactional.lock" to "false")
                )
                .load()
                .migrate()

            // Затем jOOQ читает metadata уже мигрированной PostgreSQL.
            val configuration = JooqConfiguration()
                .withJdbc(
                    Jdbc()
                        .withDriver("org.postgresql.Driver")
                        .withUrl(postgres.jdbcUrl)
                        .withUser(postgres.username)
                        .withPassword(postgres.password)
                )
                .withGenerator(
                    Generator()
                        .withDatabase(
                            Database()
                                .withName(
                                    "org.jooq.meta.postgres.PostgresDatabase"
                                )
                                .withIncludes(".*")
                                .withExcludes("flyway_schema_history")
                                .withInputSchema("public")
                                // Не прибиваем runtime SQL к схеме public.
                                .withOutputSchemaToDefault(true)
                        )
                        .withGenerate(
                            Generate()
                                .withDeprecated(false)
                                .withDaos(true)
                                .withPojos(true)
                                .withRecords(true)
                                .withSpringAnnotations(true)
                                .withDefaultCatalog(false)
                        )
                        .withTarget(
                            Target()
                                .withPackageName(
                                    "com.karandashov.demo.jooq.generated"
                                )
                                .withDirectory(
                                    outputDir.get().asFile.absolutePath
                                )
                                // Удаляем устаревшие generated files.
                                .withClean(true)
                        )
                )

            GenerationTool.generate(configuration)
        }
    }
}

// Удобный alias с привычным именем.
tasks.register("jooqCodegen") {
    group = "jooq"
    description = "Alias for generateJooq"
    dependsOn(generateJooq)
}

// Чистый checkout должен собираться одной командой.
tasks.named("compileJava") {
    dependsOn(generateJooq)
}

Здесь нет скрытой магии. Задача последовательно вызывает API трех библиотек:

  • Testcontainers создает настоящий PostgreSQL;
  • Flyway приводит его к актуальной версии схемы;
  • GenerationTool запускает jOOQ codegen.

Плюс ручного варианта — полный контроль и отсутствие зависимости от сторонних плагинов. Если нужно добавить особую миграцию, расширение, несколько схем или дополнительную настройку контейнера, все находится в одном месте.

Минус очевиден: ради одной build-задачи получилось много кода. Его придется сопровождать при обновлении Gradle, Testcontainers, Flyway и jOOQ.

Короткий вариант: dev.monosoul.jooq-docker

В актуальной версии демо ручную задачу заменяет плагин dev.monosoul.jooq-docker. Он делает то же самое: поднимает контейнер, применяет Flyway-миграции и запускает codegen. Посмотрите актуальную конфигурацию в build.gradle.kts.

Новая короткая конфигурация
build.gradle.kts
plugins {
    java
    alias(libs.plugins.jooq.docker)
    alias(libs.plugins.spring.boot)
    alias(libs.plugins.spring.dependency.management)
}

val generatedJooqDir = layout.buildDirectory.dir("generated-src/jooq/main")
val postgresImage = "postgres:${libs.versions.postgres.get()}"

sourceSets {
    main {
        java {
            srcDir(generatedJooqDir)
        }
    }
}

dependencies {
    implementation(libs.spring.boot.starter.flyway)
    implementation(libs.spring.boot.starter.jooq)
    runtimeOnly(libs.postgresql)

    // Это отдельный classpath задачи generateJooqClasses.
    jooqCodegen(platform(libs.spring.boot.dependencies))
    jooqCodegen(libs.flyway.core)
    jooqCodegen(libs.flyway.database.postgresql)
    jooqCodegen(libs.jooq.codegen)
    jooqCodegen(libs.postgresql)
}

jooq {
    withContainer {
        image {
            name = postgresImage
        }
    }
}

tasks.generateJooqClasses {
    schemas.set(listOf("public"))
    basePackageName.set("com.karandashov.demo.jooq.generated")
    migrationLocations.setFromFilesystem("src/main/resources/db/migration")
    outputDirectory.set(generatedJooqDir)
    includeFlywayTable.set(false)

    // Codegen работает с public, а runtime может использовать другую схему.
    outputSchemaToDefault.add("public")

    flywayProperties.put(
        "flyway.postgresql.transactional.lock",
        "false"
    )

    usingJavaConfig {
        database.withIncludes(".*")
        database.withExcludes("flyway_schema_history")
        generate.withDeprecated(false)
        generate.withDaos(true)
        generate.withPojos(true)
        generate.withRecords(true)
        generate.withSpringAnnotations(true)
        generate.withDefaultCatalog(false)
    }
}

tasks.named("compileJava") {
    dependsOn(tasks.generateJooqClasses)
}

Но плагин сторонний. Его функциональную разработку и сопровождение в основном ведет один человек, а значительная часть свежих обновлений зависимостей автоматизирована. Проект живой, и релизы выходят, однако bus factor все равно низкий.

Именно поэтому выше приведена ручная реализация. Если плагин перестанет подходить, мы не останемся перед черным ящиком.

Не путайте два плагина. org.jooq.jooq-codegen-gradle - официальный генератор jOOQ. dev.monosoul.jooq-docker - сторонняя обертка, которая дополнительно управляет контейнером и миграциями.

Что именно сгенерировал jOOQ

Для таблицы surveys нас интересуют четыре вида классов.

Tables.SURVEYS

Это описание таблицы и ее полей:

import static com.karandashov.demo.jooq.generated.Tables.SURVEYS;

SURVEYS.ID              // Field<UUID>
SURVEYS.QUESTION        // Field<String>
SURVEYS.ADMIN_CODE_HASH // Field<String>
SURVEYS.CREATED_AT      // Field<OffsetDateTime>

Именно эти объекты используются в DSL. Они содержат SQL-имена, типы, nullability, default values и сведения о ключах.

SurveysRecord

SurveysRecord представляет строку таблицы и наследуется от UpdatableRecordImpl. Его удобно получать из insert ... returning или selectFrom:

SurveysRecord survey = create.insertInto(SURVEYS)
        .set(SURVEYS.QUESTION, question)
        .set(SURVEYS.ADMIN_CODE_HASH, adminCodeHash)
        .returning()
        .fetchOne();

Record знает структуру таблицы и может быть attached к jOOQ configuration. Это уже объект jOOQ, а не чистая бизнес-модель.

tables.pojos.Surveys

Это обычный mutable POJO с полями, getters, setters, equals, hashCode и toString. В отличие от JPA entity он не становится managed-объектом и не обновляет базу после вызова setter.

POJO полезен, когда результат надо передать выше по слоям без зависимости от org.jooq.Record. При этом никто не заставляет использовать сгенерированные POJO: fetch(...), fetchInto(...) и RecordMapper позволяют отображать результат в собственные DTO.

SurveysDao

Generated DAO дает готовые операции для одной таблицы (похоже на JpaRepository):

surveysDao.findAll();
surveysDao.fetchOptionalById(surveyId);
surveysDao.fetchByQuestion("Какой релиз показываем?");
surveysDao.insert(survey);
surveysDao.update(survey);
surveysDao.deleteById(surveyId);

DAO работает с generated POJO. При включенном springAnnotations класс получает @Repository, а в constructor инжектится jOOQ Configuration, поэтому Spring может использовать его как обычный bean.

Подключение к Spring Boot

На runtime-стороне достаточно starter’а, JDBC driver и обычной настройки DataSource:

build.gradle.kts
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-jooq")
    runtimeOnly("org.postgresql:postgresql")
}
application.yaml
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/jooq_demo_db
    username: test
    password: test
    hikari:
      schema: surveys_schema
  jooq:
    sql-dialect: POSTGRES

Spring Boot автоматически создает DSLContext, подключает его к DataSource и определяет SQL dialect.

Обратите внимание на разные схемы:

  • во временной базе codegen работает со схемой public;
  • приложение в runtime работает со схемой surveys_schema;
  • outputSchemaToDefault запрещает генератору жестко вшивать public в SQL.

Без этого jOOQ мог бы генерировать запросы к public.surveys, даже если Hikari и Flyway настроены на другую схему. С outputSchemaToDefault PostgreSQL разрешает имя через текущий search_path.

Generated DAO: когда достаточно CRUD

В учебном проекте DAO используются там, где запрос укладывается в простую операцию над одной таблицей. Полный пример лежит в SurveyService:

SurveyService.java
@Transactional(readOnly = true)
public Surveys getSurvey(UUID surveyId, String adminCode) {
    return survey = surveysDao.fetchOptionalById(surveyId)
            .orElseThrow(SurveyNotFoundException::missing);
}

DAO удобны для:

  • поиска по primary key;
  • простых фильтров по одной колонке;
  • insert, update, delete;
  • небольших внутренних справочников;
  • кода, где запрос действительно не сложнее CRUD.

Но это не Spring Data repository. jOOQ не сгенерирует произвольный запрос из имени методаfindAllByQuestionContainingOrderByCreatedAtDesc. Набор методов определяется схемой и возможностями стандартного org.jooq.DAO.

DAO ориентированы на UpdatableRecord и не поддерживают composite primary key как тип идентификатора. Для сложных ключей и запросов все равно понадобится DSL.

Не стоит пытаться протащить DAO слишком далеко. Как только появляются join, aggregation, CTE, dynamic conditions или нестандартный returning, проще написать запрос через DSLContext.

DSLContext: основной API jOOQ

DSLContext хранит jOOQ configuration: dialect, connection provider, settings, listeners и transaction provider. В Spring Boot его достаточно получить через constructor injection. Все запросы этого раздела собраны в SurveyRepository:

Insert с returning

PostgreSQL умеет вернуть созданную строку сразу после insert. jOOQ оставляет эту возможность прямо в DSL:

public SurveysRecord createSurvey(String question, String adminCodeHash) {
    return create.insertInto(SURVEYS)
            .set(SURVEYS.QUESTION, question)
            .set(SURVEYS.ADMIN_CODE_HASH, adminCodeHash)
            .returning(
                    SURVEYS.ID,
                    SURVEYS.QUESTION,
                    SURVEYS.ADMIN_CODE_HASH,
                    SURVEYS.CREATED_AT
            )
            .fetchOne();
}

id и created_at создаются default expressions в PostgreSQL. Отдельный select после insert не нужен.

Значения question и adminCodeHash по умолчанию передаются bind parameters через JDBC PreparedStatement, а не вставляются в SQL строковой конкатенацией.

Join, aggregation и mapping

Следующий запрос выбирает активный опрос и считает ответы:

public Optional<ActiveSurveyData> findActiveSurvey(
        UUID surveyId,
        Duration surveyDuration
) {
    Field<Long> answerCount =
            count(ANSWERS.ID).cast(Long.class).as("answer_count");

    return create.select(
                    SURVEYS.ID,
                    SURVEYS.QUESTION,
                    SURVEYS.CREATED_AT,
                    answerCount
            )
            .from(SURVEYS)
            .leftJoin(ANSWERS)
                .on(ANSWERS.SURVEY_ID.eq(SURVEYS.ID))
            .where(
                    SURVEYS.ID.eq(surveyId)
                            .and(activeSurvey(surveyDuration))
            )
            .groupBy(
                    SURVEYS.ID,
                    SURVEYS.QUESTION,
                    SURVEYS.CREATED_AT
            )
            .fetchOptional(record -> new ActiveSurveyData(
                    record.get(SURVEYS.ID),
                    record.get(SURVEYS.QUESTION),
                    record.get(SURVEYS.CREATED_AT),
                    record.get(answerCount)
            ));
}

Здесь полезны сразу несколько частей API:

  • Field<Long> можно вынести в переменную и потом использовать и в select, и при чтении результата;
  • fetchOptional() явно говорит, что строка может отсутствовать;
  • lambda mapper сразу превращает jOOQ record в прикладной Java record;
  • типы полей проверяются компилятором.

Вариантов чтения результата у jOOQ много: fetch(), fetchOne(), fetchSingle(), fetchOptional(), fetchInto(), fetchMap() и lazy cursor. Их полный список есть в разделе Fetching:

  • fetchOne() возвращает null, если строки нет, и бросает исключение, если строк больше одной;
  • fetchOptional() выражает тот же контракт через Optional;
  • fetchSingle() требует ровно одну строку и бросает исключение даже при пустом результате;
  • fetchAny() просто берет одну строку и не проверяет уникальность результата.

Insert-select вместо проверки в Java

При создании ответа надо проверить, что опрос еще активен. Наивный код сделал бы сначала select, а затем insert. Между двумя запросами состояние уже могло измениться.

В демо проверка и вставка объединены:

public Optional<AnswersRecord> createAnswer(
        UUID surveyId,
        String answer,
        Duration surveyDuration
) {
    return create.insertInto(
          ANSWERS,
          ANSWERS.SURVEY_ID,
          ANSWERS.ANSWER
      )
      .select(
        create.select(val(surveyId), val(answer))
              .whereExists(
                 selectOne()
                 .from(SURVEYS)
                 .where(
                    SURVEYS.ID.eq(surveyId)
                    .and(activeSurvey(surveyDuration))
                 )
              )
        )
      .returning(
        ANSWERS.ID,
        ANSWERS.ANSWER,
        ANSWERS.CREATED_AT
      )
      .fetchOptional();
}

Если активного опроса нет, внутренняя выборка возвращает ноль строк, insert ничего не создает, а Java получает Optional.empty().

Переиспользуемые условия и plain SQL

DSL-выражения можно собирать отдельно. Метод проверки срока возвращает обычный Condition:

private Condition activeSurvey(Duration surveyDuration) {
    return DSL.condition(
            "now() < {0} + ({1} * interval '1 millisecond')",
            SURVEYS.CREATED_AT,
            val(surveyDuration.toMillis())
    );
}

Plain SQL templates полезны, когда нужное PostgreSQL-выражение неудобно собирать стандартным DSL. Но начинать с них каждый запрос не стоит: строковый SQL хуже проверяется генератором и компилятором.

Тот же принцип работает для динамических запросов. Condition, Field, Table и другие части jOOQ query model можно создавать отдельно и комбинировать. Это подробно показано в разделе Dynamic SQL.

Транзакции

В Spring Boot не нужно вручную доставать JDBC connection ради jOOQ. Starter подключает DSLContext к transaction-aware DataSource, поэтому обычный @Transactional работает:

@Transactional
public CreateSurveyResponse createSurvey(String question) {
    String adminCode = securityService.generate();
    String adminCodeHash = securityService.encode(adminCode);

    SurveysRecord survey =
            surveyRepository.createSurvey(question, adminCodeHash);

    return toResponse(survey, adminCode);
}

У jOOQ есть и собственный create.transaction(...), но внутри обычного Spring Boot сервиса смешивать две модели без необходимости не надо. Если проект уже использует Spring TX — @Transactional проще и привычнее.

Практический порядок работы

После первоначальной настройки работа над проектом будет сплошным удовольствием:

  1. Добавляем Flyway-миграцию.
  2. Запускаем ./gradlew generateJooqClasses или обычный ./gradlew build.
  3. Смотрим, как изменилась generated Java-модель.
  4. Исправляем compile errors в прикладном коде.
  5. Для простого CRUD используем generated DAO.
  6. Для настоящих SQL-запросов берем DSLContext.

Не редактируйте generated classes! Не запускайте codegen против случайной общей dev-базы! Не прячьте сложный SQL за пятью слоями абстракций только ради слова Repository!

jOOQ не освобождает от знания SQL. Он делает обратное — позволяет нормально использовать SQL в Java, не превращая запросы в набор непроверяемых строк. Когда схема, codegen и сборка связаны между собой, исчезает самая неприятная часть старта. Дальше остается обычная работа с базой, только с типами и подсказками IDE.