jOOQ Style Guide
Свод правил использования jOOQ в Java-сервисах: repository pattern, multiset для nested-fetch, filter builders, record-domain mapping, SelectMode, view repositories, transaction boundaries. Коды R-JOOQ-* для AI-ревью.
Свод правил использования jOOQ в Java-сервисах команды UCP. Каждое правило идентифицируется кодом (R-JOOQ-CFG-1, R-JOOQ-QRY-X2 и т. п.) — скиллы ucp-jooq-design и ucp-jooq-review цитируют эти коды в findings.
Гайд опирается на:
BS-17/BS-18/BS-19/BS-20(см. Spring Bootstrap — почему jOOQ и только generated классы.PG-L-040/PG-L-041(см. PostgreSQL: блокировки) — locking-синтаксис и обязательность@Transactional.
Этот документ — runtime-слой над схемой: как писать репозитории и запросы, не что использовать.
Содержание
- Конфигурация codegen —
R-JOOQ-CFG-* - Repository-pattern —
R-JOOQ-REPO-* - DSLContext —
R-JOOQ-CTX-* - Построение запросов —
R-JOOQ-QRY-* - Multiset для nested-fetch —
R-JOOQ-MS-* - Filter-builders —
R-JOOQ-FLT-* - Маппинг record ↔ domain —
R-JOOQ-MAP-* - Пагинация —
R-JOOQ-PAG-* - Lock-режимы —
R-JOOQ-LCK-* - Транзакции —
R-JOOQ-TX-* - View-репозитории —
R-JOOQ-VIEW-* - Антипаттерны — сводка
R-JOOQ-*-X*
1. Конфигурация codegen
1.1 Обязательно
- R-JOOQ-CFG-1. Codegen запускается поверх живой схемы PostgreSQL, поднятой через Testcontainers и накаченной Liquibase-миграциями. Не используется ни статический xml, ни ddl-файлы. Это даёт единый источник правды:
migrations/→ Liquibase → PG → jOOQ.generateJooqWithPostgres: 1. start Testcontainers postgres 2. ./gradlew liquibaseUpdate 3. introspect via PostgresDatabase 4. generate POJO + Record + Tables - R-JOOQ-CFG-2. В команде есть собственный gradle-плагин
jooq-postgresql-generator-plugin, который инкапсулирует Testcontainers + Liquibase + codegen. Сервис подключает его и не дублирует Testcontainers-конфигурацию вручную. - R-JOOQ-CFG-3. Generated POJO именуются по паттерну
<Table>_Pojo(черезMatcherStrategy/PASCAL). - R-JOOQ-CFG-4. TIMESTAMP/TIMESTAMPTZ-колонки маппятся на
java.time.OffsetDateTimeчерезforcedTypes.LocalDateTimeзапрещён для persistent-полей (см.R-JOOQ-CFG-X3). - R-JOOQ-CFG-5. Generated код кладётся в
build/generated/sources/jooq/main, не коммитится в VCS. Регенерируется на каждом./gradlew compileJava. - R-JOOQ-CFG-6. Forced types для domain-enum'ов настраиваются через
<enumConverter>true</enumConverter>если java enum уже существует вcore/. Иначе jOOQ сам генерирует enum (см.BS-19).<forcedType> <userType>ru.example.OrderStatus</userType> <enumConverter>true</enumConverter> <includeExpression>.*\.STATUS</includeExpression> </forcedType> - R-JOOQ-CFG-7.
setFluentSetters(true)— generated setters возвращаютthis, чтобы поддержать chained-builder в маппере.
1.2 Запрещено
- R-JOOQ-CFG-X1.
setDaos(true)— generated DAO в репозитории не используются (см.R-JOOQ-REPO-X1). Генерация лишних классов. - R-JOOQ-CFG-X2.
setImmutablePojos(true)— POJO мутабельны, иначе ломается chained-set в маппере. Иммутабельность обеспечивается на уровне domain-entity, не POJO. - R-JOOQ-CFG-X3.
LocalDateTimeв forced types для timestamptz-колонок. Не несёт zone-информации, провоцирует bag-в-bag ошибки. ТолькоOffsetDateTime. - R-JOOQ-CFG-X4. Codegen из xml/ddl-файлов вместо живой схемы. Расходится с реальной БД.
2. Repository-pattern
2.1 Обязательно
- R-JOOQ-REPO-1. Domain-репозиторий — interface в
core/domain/repository/. jOOQ-имплементация — в модулеpersistence/, имя классаJooq<X>Repository implements <X>Repository.// core/domain/repository/OrderRepository.java public interface OrderRepository { Optional<Order> findById(Long id, SelectMode mode); PaginationView<Order> findAll(OrderFilter filter, int page, int size); void save(Order order); } // persistence/.../order/JooqOrderRepository.java @Repository @RequiredArgsConstructor public class JooqOrderRepository implements OrderRepository { private final DSLContext dslContext; private final OrderDomainRecordMapper mapper; private final OrderFilterConditionBuilder conditionBuilder; } - R-JOOQ-REPO-2. Конструкторное внедрение через Lombok
@RequiredArgsConstructor. Поляprivate final. Никакого@Autowiredна полях. - R-JOOQ-REPO-3. Public методы репозитория принимают и возвращают domain-объекты (entity, value-object,
PaginationView<T>), не jOOQ Record и не POJO. POJO — деталь реализации. - R-JOOQ-REPO-4. Все public read-методы принимают параметр
SelectMode mode(см. раздел 9). По умолчаниюSelectMode.NO_LOCKдля query-handler'ов. - R-JOOQ-REPO-5. Сложные части запроса (multiset-сборка, сортировка, лок) выносятся в
privateметоды того же класса:buildChildrenSelect(),toSortFields(),applyLock(). Public-метод читается линейно, без вложенных DSL-выражений. - R-JOOQ-REPO-6. Каждый репозиторий покрыт интеграционным тестом против Testcontainers PostgreSQL — без mock'ов
DSLContext.
2.2 Запрещено
- R-JOOQ-REPO-X1. Использование jOOQ Generated DAO (
*Dao) — запрещеноBS-18иR-JOOQ-CFG-X1. Вместо них пишутся свои репозитории. - R-JOOQ-REPO-X2. Прямой
JooqOrderRepositoryinjected в use-case handler. Хендлеры зависят от domain-интерфейса, не от persistence-имплементации. - R-JOOQ-REPO-X3. Spring Data JDBC, JPA, Hibernate, MyBatis, JdbcTemplate в любой форме — нарушение
BS-17. - R-JOOQ-REPO-X4. Бизнес-логика в репозитории. Репозиторий — query/persistence operator, без
if (order.status == ...) ....
3. DSLContext
3.1 Обязательно
- R-JOOQ-CTX-1.
DSLContext— Spring-bean (поднимается стартеромspring-boot-starter-jooq). Инжектится в репозиторий через конструктор. Один на ApplicationContext. - R-JOOQ-CTX-2.
DSLContextпотокобезопасен при условии, чтоConfigurationиммутабельна. Кеши внутри (reflection lookup для record-mapping) разделяются между потоками — поэтому переиспользуем singleton, не создаём новый на запрос. - R-JOOQ-CTX-3. Если нужна модифицированная конфигурация (
Settings,RecordMapperProvider), создаётся production-grade Bean, не локальныйDSL.using(...)на ходу.
3.2 Запрещено
- R-JOOQ-CTX-X1.
DSL.using(connection, ...)илиDSL.using(dataSource)в коде репозитория. Spring-bean уже сконфигурирован, вручную создавать не надо. - R-JOOQ-CTX-X2. Хранение
ConnectionилиDSLContextв state'е репозитория и переиспользование между методами вне Spring-проводки.
4. Построение запросов
4.1 Обязательно
- R-JOOQ-QRY-1. Static imports для DSL-функций (
select,selectFrom,noCondition,multiset,field,case_):import static org.jooq.impl.DSL.*; import static ru.example.adapter.out.postgres.SelectMultisetAliasKeys.*;В каждом файле репозитория. Делает запросы читаемыми, как SQL.
- R-JOOQ-QRY-2.
selectFrom(TABLE)— для fetch'а полной строки с последующим.fetchInto(Pojo.class).select(TABLE.FIELD1, TABLE.FIELD2, multiset(...))— когда проектируем отдельные колонки или собираем multiset. - R-JOOQ-QRY-3. Fetch-методы под цель:
.fetchOptional()/.fetchOptionalInto(X.class)— single-row, который может отсутствовать..fetchOne()— single-row, отсутствие = баг (броситNoDataFoundException/TooManyRowsException)..fetch()/.fetchInto(X.class)— список..fetchExists()— для проверки существования. Не.fetchCount() > 0..fetchCount()— только если число действительно нужно (для пагинации).
- R-JOOQ-QRY-4. Insert/Update/Delete — через DSL:
dslContext.insertInto(...),update(...).set(...),deleteFrom(...).where(...). Либо черезexecuteInsert(record)если нужен auto-generated id. - R-JOOQ-QRY-5. Bind-параметры — позиционные через DSL (
.where(ID.eq(id))), не строковые черезcondition("id = ?"). - R-JOOQ-QRY-6. Сортировка — отдельный private helper
toSortFields(Sort<X> sort) → List<SortField<?>>, переключающий поля domain-enum'а на jOOQ-колонки.private List<SortField<?>> toSortFields(Sort<OrderSortField> sort) { return sort.fields().stream() .map(f -> switch (f.field()) { case CREATED_AT -> orders.CREATED_AT.sort(f.direction()); case TOTAL_AMOUNT -> orders.TOTAL_AMOUNT.sort(f.direction()); }) .toList(); } - R-JOOQ-QRY-7. EXISTS-подзапросы для related-таблиц через
DSL.exists(select(...).from(other).where(...)). Не делаем join + group by ради проверки наличия.
4.2 Запрещено
- R-JOOQ-QRY-X1.
dslContext.fetch("SELECT ... FROM ...")— plain SQL. Помечено@PlainSQLв jOOQ — риск SQL-injection и обход type-safety. - R-JOOQ-QRY-X2.
condition("status = " + value)— конкатенация в plain SQL. Прямой SQL-injection. - R-JOOQ-QRY-X3.
record.set...(); record.store()для UPDATE существующих записей. Скрывает что и где обновляется. ИспользуйdslContext.update(table).set(field, value).where(...). - R-JOOQ-QRY-X4.
.fetchOne()когда отсутствие записи — нормальный кейс. Используй.fetchOptional(), чтобы не ловитьNoDataFoundException. - R-JOOQ-QRY-X5.
.fetchCount() > 0для проверки существования. Тяжелее.fetchExists()на больших таблицах. - R-JOOQ-QRY-X6.
.into(...)после.fetch()без типобезопасной проекции. Используй.fetchInto(Pojo.class).
5. Multiset для nested-fetch
multiset() — стандартный паттерн eager-loading вложенных коллекций одним SQL-запросом. Заменяет N+1 и lazy-инициализацию из JPA.
5.1 Обязательно
- R-JOOQ-MS-1. Каждая alias-key для
.as("...")живёт в одном месте —SelectMultisetAliasKeysв модулеpersistence/. Константа на ключ, без magic strings:public final class SelectMultisetAliasKeys { public static final String TICKETS = "tickets"; public static final String INSURANCES = "insurances"; public static final String RECEIPTS = "receipts"; private SelectMultisetAliasKeys() {} } - R-JOOQ-MS-2. Извлечение nested-коллекций из result-record — через утилиту
RecordMappingUtils:List<TicketsPojo> tickets = RecordMappingUtils.getPojoList(record, TICKETS, TicketsPojo.class); Result<?> insuranceRecords = RecordMappingUtils.getRecordList(record, INSURANCES);Никакого
(Result<?>) record.get(TICKETS, Result.class)напрямую — теряется типобезопасность и читаемость. - R-JOOQ-MS-3. Multiset вкладывается в multiset для eager-fetch'а 2-уровневой иерархии (Order → Ticket → Insurance):
multiset( select( tickets.asterisk(), multiset(selectFrom(insurances).where(insurances.TICKET_ID.eq(tickets.ID))) .as(INSURANCES) ) .from(tickets) .where(tickets.ORDER_ID.eq(orders.ID)) ).as(TICKETS) - R-JOOQ-MS-4. Если parents много (batch find), вместо multiset делаем два запроса: parent-IDs → children
WHERE parent_id IN (...)+ ручная зашивка черезfetchMap. Multiset на тысячах parent'ов даёт большие result-row'ы и медленные транспорт.
5.2 Запрещено
- R-JOOQ-MS-X1. Magic-string как alias:
.as("tickets")непосредственно в коде запроса. Только изSelectMultisetAliasKeys. - R-JOOQ-MS-X2. Lazy-fetch nested-коллекции отдельным запросом из маппера. N+1 запросов = регресс производительности. Multiset или batch-fetch.
6. Filter-builders
6.1 Обязательно
- R-JOOQ-FLT-1. Сложные WHERE-условия выносятся в
<X>FilterConditionBuilder— Spring@Component, не статическая утилита (нужны зависимости —DSLContextдля подзапросов).@Component @RequiredArgsConstructor public class OrderFilterConditionBuilder { private final DSLContext dslContext; public Condition buildCondition(OrderFilter filter) { Condition c = noCondition(); c = andIfNotNull(c, filter.id(), orders.ID::eq); c = andIfNotNull(c, filter.statusFrom(), s -> orders.STATUS.in(s)); c = andIfNotEmpty(c, filter.customerIds(), orders.CUSTOMER_ID::in); return c; } } - R-JOOQ-FLT-2.
<X>Filter— record/POJO вcore/domain/repository/filter/, иммутабельный. Поля nullable, чтобы фильтр игнорировался если значение не задано. - R-JOOQ-FLT-3. Условие начинается с
Condition c = noCondition(). Это нейтральный элемент — если ни один if не сработает, WHERE будет полным1=1. - R-JOOQ-FLT-4. Чейнинг через утилиту
FilterConditionHelper:public final class FilterConditionHelper { public static <T> Condition andIfNotNull(Condition c, T value, Function<T, Condition> mapper) { ... } public static <T> Condition andIfNotEmpty(Condition c, Collection<T> coll, Function<Collection<T>, Condition> mapper) { ... } public static Condition andIfTrue(Condition c, boolean cond, Supplier<Condition> mapper) { ... } } - R-JOOQ-FLT-5. EXISTS для cross-table фильтров (заказ имеет платёж, тикет имеет страховку):
c = andIfNotNull(c, filter.sberId(), sid -> exists(select(payment.ID).from(payment) .where(payment.ORDER_ID.eq(orders.ID).and(payment.SBER_ID.eq(sid))))); - R-JOOQ-FLT-6.
<X>FilterConditionBuilder— единственное место, где знание о<X>Filterвстречается с jOOQ-таблицами. Из репозитория передаём filter, обратно получаемCondition.
6.2 Запрещено
- R-JOOQ-FLT-X1. Inline if-цепочки внутри метода репозитория, ветвящие WHERE. На третьем поле фильтра нечитаемо.
- R-JOOQ-FLT-X2. Конкатенация SQL-фрагментов через String — теряется type-safety и есть риск SQL-injection.
7. Маппинг record ↔ domain
7.1 Обязательно
- R-JOOQ-MAP-1. Маппер — plain Java class (Spring
@Component), не MapStruct interface. Причина: маппинг между jOOQ POJO и domain entity содержит ручную логику (assemble aggregate, разворачивать multiset-result, конвертировать enum), которую MapStruct не покрывает удобно.@Component @RequiredArgsConstructor public class OrderDomainRecordMapper { private final TicketDomainRecordMapper ticketMapper; private final JooqJsonbHelper jsonb; public Order toDomain(OrdersPojo pojo) { ... } public OrdersPojo fromDomain(Order entity) { ... } public Order assembleAggregate(OrdersPojo header, List<TicketsPojo> tickets) { ... } } - R-JOOQ-MAP-2. Двусторонний маппинг:
toDomain(pojo)иfromDomain(entity). Для сборки агрегата из плоских POJO — отдельныйassembleAggregate(...), принимающий все части агрегата как параметры. - R-JOOQ-MAP-3. Enum-перевод — через generated jOOQ enum + domain enum:
// jooq → domain status = pojo.getStatus() != null ? OrderStatus.fromValue(pojo.getStatus().getLiteral()) : null; // domain → jooq pojo.setStatus(ru.example.jooq.enums.OrderStatus.lookupLiteral(domainStatus.getValue()));Или через
forcedTypeс<enumConverter>true</enumConverter>— тогда конвертация автоматическая (см.R-JOOQ-CFG-6). - R-JOOQ-MAP-4. JSONB-колонки — через
JooqJsonbHelper(Spring-bean с инжектируемымObjectMapper):public <T> T deserialize(JSONB jsonb, Class<T> clazz) { ... } public JSONB serialize(Object value) { ... } - R-JOOQ-MAP-5. Timestamp-конверсия не делается в маппере вручную —
forcedType OffsetDateTime(см.R-JOOQ-CFG-4) делает это в codegen-time. - R-JOOQ-MAP-6. Каждый child-маппер инжектится в parent (
OrderDomainRecordMapperзависит отTicketDomainRecordMapper). Делегация — не наследование, не статика. - R-JOOQ-MAP-7. Маппер расположен рядом с репозиторием в
persistence/.../<entity>/, не вcore/. Это persistence-деталь.
7.2 Запрещено
- R-JOOQ-MAP-X1. Возврат POJO/Record из public-метода репозитория. POJO — внутренний тип, наружу только domain.
- R-JOOQ-MAP-X2. Создание domain-entity напрямую из generated record без маппера. Логика конструирования размазывается.
- R-JOOQ-MAP-X3. MapStruct-interface для record→domain, если у маппинга есть assemble-логика или enum-конверсия. MapStruct ок, только если оба типа — POJO с одинаковыми полями (бывает редко в этом слое).
8. Пагинация
8.1 Обязательно
- R-JOOQ-PAG-1. Domain-репозиторий возвращает
PaginationView<T>— record/POJO вcore/domain/repository/:public record PaginationView<T>( List<T> items, long total, int count, int page, int size ) { public int getTotalPages() { return (int) Math.ceil((double) total / size); } } - R-JOOQ-PAG-2. Offset-based пагинация в репозитории:
query.offset((long) page * size).limit(size).fetch(...)— items.- Отдельный
fetchCountquery для total. - Контракт API —
R-QRY-4(REST: page 1-based) в публичном REST. В репозитории — 0-based, конвертируется на handler-уровне.
- R-JOOQ-PAG-3. Cursor-based (keyset) пагинация — для часто меняющихся данных (ленты, уведомления):
query.where(orders.CREATED_AT.lt(cursor.createdAt()) .or(orders.CREATED_AT.eq(cursor.createdAt()).and(orders.ID.lt(cursor.id())))) .orderBy(orders.CREATED_AT.desc(), orders.ID.desc()) .limit(size + 1) // +1 чтобы определить hasNext .fetch(...);Total не вычисляется (дорого + не нужен).
- R-JOOQ-PAG-4. Cursor — непрозрачный токен на уровне API (см. R-QRY-5). На уровне репозитория cursor — record
(createdAt, id)или подобный composite-key. - R-JOOQ-PAG-5. Total для коллекции — через
.fetchCount(), не через получение всех записей и.size().
8.2 Запрещено
- R-JOOQ-PAG-X1.
fetchAll()безLIMIT/OFFSETдля UI-эндпоинтов. На большой таблице — OOM или таймаут. - R-JOOQ-PAG-X2. Использование
count(*)(через query builder) на больших таблицах в горячем пути — sequential scan. Если нужен appromix-count — использовать оценки PG (pg_class.reltuples).
9. Lock-режимы
9.1 Обязательно
- R-JOOQ-LCK-1. В
core/domain/repository/SelectMode.java— domain-enum:public enum SelectMode { NO_LOCK, // обычный SELECT FOR_UPDATE, // SELECT FOR UPDATE FOR_UPDATE_SKIP_LOCKED // FOR UPDATE SKIP LOCKED для batch-схедулеров }Каждый репозиторный метод чтения принимает
SelectMode modeи применяет. - R-JOOQ-LCK-2.
applyLock(query, mode)— private helper в репозитории:private <Q extends SelectForUpdateOfStep<?>> Q applyLock(Q query, SelectMode mode) { return switch (mode) { case NO_LOCK -> query; case FOR_UPDATE -> (Q) query.forUpdate(); case FOR_UPDATE_SKIP_LOCKED -> (Q) query.forUpdate().skipLocked(); }; } - R-JOOQ-LCK-3. Любой
forUpdate()/skipLocked()-запрос — внутри@Transactional-метода (см.PG-L-041в PostgreSQL: блокировки). Иначе jOOQ откроет/закроет соединение, лок отпустится мгновенно. - R-JOOQ-LCK-4. Для optimistic locking используется
version-колонка в схеме (см.PG-L-051), а неwithExecuteWithOptimisticLocking(true)вSettings. Причина:version-колонка явная и видима всем (миграциям, debug, ручным правкам),Settings— скрытая магия.
9.2 Запрещено
- R-JOOQ-LCK-X1.
forUpdate()без@Transactionalна вызывающем методе. Лок не удержится — баг под нагрузкой. - R-JOOQ-LCK-X2.
withExecuteWithOptimisticLocking(true)глобально вSettings. Делает поведение запросов скрытым; конфликт-error из ниоткуда. - R-JOOQ-LCK-X3. Использование
forUpdate()для read-only query — лишнее блокирование.NO_LOCKдля запросов из query-handler'ов.
10. Транзакции
10.1 Обязательно
- R-JOOQ-TX-1.
@Transactionalставится на handler (UseCaseHandler.handle()), не на репозиторий. Граница транзакции — бизнес-операция.@Component @Transactional // RW по умолчанию public class CreateOrderCommandHandler implements UseCaseHandler<CreateOrderCommand, Order> { ... } @Component @Transactional(readOnly = true) public class GetOrdersQueryHandler implements UseCaseHandler<GetOrdersQuery, PaginationView<Order>> { ... } - R-JOOQ-TX-2.
@Transactional(readOnly = true)обязателен на query-handler'ах. Spring/JOOQ передадут это в драйвер — PG может отдать запрос на standby. - R-JOOQ-TX-3. Если в одном handler нужны изоляция или propagation отличные от дефолтных — указываются явно:
@Transactional(isolation = Isolation.REPEATABLE_READ). Default —READ_COMMITTED(PG default).
10.2 Запрещено
- R-JOOQ-TX-X1.
@Transactionalна репозитории. Репозиторий — query/persistence operator без знания о бизнес-границах. Граница — handler. - R-JOOQ-TX-X2.
@Transactionalна сервисном слое в обход handler'ов. У UCP — handler единственная точка транзакции. - R-JOOQ-TX-X3. Программное управление транзакцией через
dslContext.transaction(...)если уже есть Spring@Transactional. Возникает вложенная TX через Savepoint — ненужная сложность.
11. View-репозитории
11.1 Обязательно
- R-JOOQ-VIEW-1. Если read-проекция отличается от агрегата (флаговый список, сводка, отчёт), вводится отдельный интерфейс
<X>ViewRepositoryрядом с<X>Repository:// core/domain/repository/ public interface OrderRepository { // CRUD агрегата Optional<Order> findById(Long id, SelectMode mode); void save(Order order); } public interface OrderViewRepository { // оптимизированные read-проекции PaginationView<OrderSummary> findSummaries(OrderFilter filter, int page, int size); List<OrderRow> findExport(OrderFilter filter); } - R-JOOQ-VIEW-2. View-репозиторий читает только то, что нужно (без full multiset, без heavy joins). Возвращает domain-friendly read-DTO (
OrderSummary,OrderRow), не агрегат. - R-JOOQ-VIEW-3. Read-DTO — record в
core/domain/repository/view/илиcore/dto/view/. Иммутабельный, без бизнес-логики.
11.2 Запрещено
- R-JOOQ-VIEW-X1. Перегружать
<X>Repositoryотдельными методами видаfindSummaries,findForExport. Это раздувает основной интерфейс и смешивает write-aggregate и read-projection — два разных контракта.
12. Антипаттерны
Сводка ссылок на запрещающие правила (X-коды) — единая точка для быстрой проверки.
| Антипаттерн | Правило | Корректно |
|---|---|---|
| Generated DAO в репозитории | R-JOOQ-CFG-X1, R-JOOQ-REPO-X1 | Jooq<X>Repository implements <X>Repository |
Plain SQL dslContext.fetch("...") | R-JOOQ-QRY-X1 | DSL: selectFrom(...) |
| Конкатенация в condition | R-JOOQ-QRY-X2 | bind через .eq(value) |
record.set...().store() для UPDATE | R-JOOQ-QRY-X3 | update(table).set(...) |
.fetchOne() где допустим null | R-JOOQ-QRY-X4 | .fetchOptional() |
.fetchCount() > 0 | R-JOOQ-QRY-X5 | .fetchExists() |
| Magic-string alias в multiset | R-JOOQ-MS-X1 | константа из SelectMultisetAliasKeys |
| Lazy-fetch nested через отдельный запрос | R-JOOQ-MS-X2 | multiset() или batch-fetch |
| Inline ветвящийся WHERE | R-JOOQ-FLT-X1 | <X>FilterConditionBuilder + andIfNotNull |
| POJO/Record возвращается наружу | R-JOOQ-MAP-X1 | mapper → domain |
MapStruct с assemble-логикой | R-JOOQ-MAP-X3 | plain Java mapper |
DSL.using(...) вручную | R-JOOQ-CTX-X1 | Spring-bean DSLContext |
LocalDateTime для timestamptz | R-JOOQ-CFG-X3 | OffsetDateTime |
setImmutablePojos(true) | R-JOOQ-CFG-X2 | мутабельные POJO |
| Codegen из xml/ddl | R-JOOQ-CFG-X4 | Liquibase + Testcontainers + introspection |
forUpdate() без @Transactional | R-JOOQ-LCK-X1, PG-L-041 | @Transactional на handler'е |
Глобальный optimistic-locking через Settings | R-JOOQ-LCK-X2 | version-колонка, PG-L-051 |
@Transactional на репозитории | R-JOOQ-TX-X1 | на handler'е |
Вложенные read-методы в основном <X>Repository | R-JOOQ-VIEW-X1 | отдельный <X>ViewRepository |
fetchAll() без LIMIT в UI-эндпоинте | R-JOOQ-PAG-X1 | LIMIT/OFFSET или cursor |
| JdbcTemplate / JPA / MyBatis | R-JOOQ-REPO-X3, BS-17 | jOOQ |
Финальная сводка: правил «Обязательно» — около 50, «Запрещено» — около 25. Findings ревью цитируют конкретный код (R-JOOQ-MS-1, R-JOOQ-LCK-X1).