Опирается на правила: R-JOOQ-CFG-1R-JOOQ-CFG-7 и R-JOOQ-CFG-X1R-JOOQ-CFG-X4 из jOOQ Style Guide → раздел 1. Конфигурация codegen.

Важно знать

  • Источник правды — живая PostgreSQL, поднятая Testcontainers и накаченная Liquibase. Не xml-схема, не ddl-файлы.
  • Generated код лежит в build/generated/sources/jooq/main, не коммитится в git. Регенерируется на каждом compileJava.
  • timestamptzOffsetDateTime. LocalDateTime для persistent-полей запрещён — теряется zone.
  • setFluentSetters(true) — обязательно (chained setter в маппере). setImmutablePojos(true) — запрещено.
  • setDaos(true) — запрещено: generated DAO мы не используем, пишем свои репозитории.
  • Domain-enum'ы маппятся через <enumConverter>true</enumConverter> в forced types.

Codegen — это то, что превращает CREATE TABLE в Java-классы, с которыми работает репозиторий. Если codegen берёт xml или ddl-файл, рано или поздно он разойдётся с реальной БД: миграция накатилась, ddl-файл забыли обновить, тип в Java остался String вместо UUID. Чтобы этого не было, codegen берёт схему из живой PostgreSQL — той же, что прод. Раскрытие правил R-JOOQ-CFG-* ниже.

Pipeline codegen — Liquibase → PG → introspection

R-JOOQ-CFG-1: единый источник правды — migrations/ → Liquibase → PostgreSQL → jOOQ.

Псевдокод процесса:

generateJooqWithPostgres:
  1. start Testcontainers postgres
  2. ./gradlew liquibaseUpdate                  # накатывает миграции на свежий PG
  3. introspect via PostgresDatabase             # читает информационную схему PG
  4. generate POJO + Record + Tables             # пишет в build/generated/...

Никакой xml-схемы (information_schema.xml), никаких database.xml с CREATE TABLE-выписками. Если в migrations/ появилась новая колонка, она появится в jOOQ-классе автоматически на следующем ./gradlew compileJava. Если миграция сломана — codegen упадёт при liquibaseUpdate, не дав собрать сервис. Это тот самый «fail fast» на стадии сборки.

R-JOOQ-CFG-2: pipeline инкапсулирован в собственный gradle-плагин jooq-postgresql-generator-plugin. Сервис подключает его и не разбирается с Testcontainers / Liquibase / introspector настройками. Это снимает 200 строк boilerplate с каждого build.gradle.kts и гарантирует, что во всех сервисах процесс одинаковый.

Generated код не коммитится

R-JOOQ-CFG-5: build/generated/sources/jooq/main — в .gitignore. Регенерируется при каждой сборке.

Почему так:

  • PR-diff не загромождается. Изменения миграции = N строк в migrations/db.changelog-*.yaml. Generated-код переписывается тысячами строк автоматически — если коммитить, PR превратится в кашу.
  • Конфликты при merge. Два параллельных PR с миграциями = трёхэтапный rebase generated-классов. Бесполезная работа.
  • Источник правды один — миграции. Generated всегда выводится из них.

Подводный камень: IDE по-умолчанию не видит build/generated/sources/jooq/main как source root. Решается через idea.module.generatedSourceDirs в build.gradle.kts плагина — выставляется автоматически, никакой ручной конфигурации.

OffsetDateTime для timestamptz

R-JOOQ-CFG-4 и R-JOOQ-CFG-X3: TIMESTAMP/TIMESTAMPTZ → java.time.OffsetDateTime через forced types. LocalDateTime запрещён.

jooq {
    forcedTypes {
        forcedType {
            userType = "java.time.OffsetDateTime"
            includeTypes = "TIMESTAMPTZ|TIMESTAMP"
        }
    }
}

Почему не LocalDateTime:

  • LocalDateTime — это «дата-время без зоны». PG-колонка timestamptz хранит момент во времени (offset нормализован к UTC). При чтении в LocalDateTime теряется zone, при записи — добавляется JVM-default-zone, что приводит к багу «у клиента в Москве заказ создан в 03:00 ночи, хотя реально в 06:00».
  • Instant сошёл бы, но он не различает «UTC» и «момент, зафиксированный в зоне» — для логов и API лучше OffsetDateTime, который явно несёт offset.
  • Для бизнес-дат без времени (день рождения, дата релиза) — LocalDate. Для бизнес-времени без даты (рабочее окно «с 9 до 18») — LocalTime. Это не противоречит правилу — правило про timestamptz.

См. PG Style Guide — правила выбора колоночных типов для дат и времени.

Domain-enum'ы через enumConverter

R-JOOQ-CFG-6: если в core/ уже есть enum OrderStatus, jOOQ маппит колонку на него:

<forcedType>
    <userType>ru.example.OrderStatus</userType>
    <enumConverter>true</enumConverter>
    <includeExpression>.*\.STATUS</includeExpression>
</forcedType>

Что это даёт:

  • В коде репозитория order.getStatus() возвращает OrderStatus (domain), не OrdersStatus (jOOQ-generated). Маппер не делает ручной конверсии — за это отвечает codegen.
  • При добавлении значения в domain-enum миграцию пишем сами; jOOQ просто перевыводит generated-enum. Если значения расходятся — codegen-build не упадёт, но мапперr на runtime получит IllegalArgumentException. Тест на актуальность enum-набора в BaseIntegrationTest ловит это до прода.

Альтернатива — оставить jOOQ-generated enum и конвертировать руками в маппере. Это работает, но добавляет boilerplate (OrderStatus.fromValue(pojo.getStatus().getLiteral()) в каждом маппере). См. BS-19 в Spring Bootstrap — почему предпочитаем generated.

POJO мутабельны, setters fluent

R-JOOQ-CFG-7 и R-JOOQ-CFG-X2: setFluentSetters(true) обязателен, setImmutablePojos(true) запрещён.

Fluent setters позволяют chained-builder в маппере:

return new OrdersPojo()
    .setId(entity.id())
    .setCustomerId(entity.customer().id())
    .setStatus(entity.status())
    .setCreatedAt(entity.createdAt());

Без fluent setters это пять отдельных строк pojo.setX(...) и предваряющий OrdersPojo pojo = new OrdersPojo();. Маппер раздувается, читаемость падает.

setImmutablePojos(true) сделал бы POJO immutable record-like — конструктор со всеми полями. Но: типичная миграция «добавили колонку» ломает все вызовы конструктора в маппере, а ещё запись через INSERT требует пройтись по конструктору в порядке, в котором jOOQ сгенерировал поля (рискованно — порядок меняется при alter table). Мутабельные POJO с fluent setters решают обе проблемы. Immutability — забота domain entity, не POJO.

POJO — <Table>_Pojo

R-JOOQ-CFG-3: generated POJO именуются <Table>_Pojo через MatcherStrategy или PASCAL-конвенцию.

Без переопределения jOOQ генерирует <Table>Pojo (без подчёркивания). Команда выбрала _Pojo, чтобы:

  • Не путать generated POJO с domain entity. Order (domain) и Order_Pojo (jOOQ) сразу видны в IDE-autocomplete как разные классы.
  • Дать визуальный сигнал, что класс не для бизнес-логики — он только для persistence-маппинга.

В коде статей этого сайта POJO упоминаются как OrdersPojo (без подчёркивания) — это исторический формат до выкатки правила. Новый код всегда Orders_Pojo. Команда занимается миграцией.

Что запрещено

АнтипаттернПравилоЧто вместо
setDaos(true)R-JOOQ-CFG-X1Свой Jooq<X>Repository implements <X>Repository (см. Repository pattern)
setImmutablePojos(true)R-JOOQ-CFG-X2Мутабельные POJO с fluent setters; immutability в domain entity
LocalDateTime в forcedTypes для timestamptzR-JOOQ-CFG-X3OffsetDateTime
Codegen из xml/ddlR-JOOQ-CFG-X4Liquibase + Testcontainers + introspection

Куда дальше