Опирается на правила:
R-JOOQ-CFG-1…R-JOOQ-CFG-7иR-JOOQ-CFG-X1…R-JOOQ-CFG-X4из jOOQ Style Guide → раздел 1. Конфигурация codegen.
Важно знать
- Источник правды — живая PostgreSQL, поднятая Testcontainers и накаченная Liquibase. Не xml-схема, не ddl-файлы.
- Generated код лежит в
build/generated/sources/jooq/main, не коммитится в git. Регенерируется на каждомcompileJava.timestamptz→OffsetDateTime.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 для timestamptz | R-JOOQ-CFG-X3 | OffsetDateTime |
| Codegen из xml/ddl | R-JOOQ-CFG-X4 | Liquibase + Testcontainers + introspection |
Куда дальше
- jOOQ Style Guide → раздел 1. Конфигурация codegen — нормативные формулировки.
- Repository pattern в jOOQ — что делают со сгенерированными типами.
- PG Style Guide — выбор колоночных типов и почему
timestamptz, а неtimestamp. - Spring Bootstrap → BS-17..20 — почему jOOQ единственный persistence-стек.