jOOQ Style Guide

jOOQ Style Guide — persistence-слой Java-биндинга UCP (R-JOOQ-*): repository-pattern, multiset, мапперы, транзакции. Python-аналог — SQLAlchemy, скиллы ucp-py-sqlalchemy-*.

Профиль Python: статьи ниже описывают Java-биндинг этого контракта. Python-биндинг (style-guide и скиллы ucp-py-*) — в репозитории скиллов ↗.

Статья внедрена в скилл AI-агента ucp-jooq-design / ucp-jooq-review

Языко-специфичный раздел Java-биндинга — нейтрального контракта нет, у каждого языка свой persistence-аналог (Python: SQLAlchemy 2.0 + Alembic, скиллы ucp-py-sqlalchemy-*).

Свод правил использования 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-слой над схемой: как писать репозитории и запросы, не что использовать.


Содержание

  1. Конфигурация codegen — R-JOOQ-CFG-*
  2. Repository-pattern — R-JOOQ-REPO-*
  3. DSLContext — R-JOOQ-CTX-*
  4. Построение запросов — R-JOOQ-QRY-*
  5. Multiset для nested-fetch — R-JOOQ-MS-*
  6. Filter-builders — R-JOOQ-FLT-*
  7. Маппинг record ↔ domain — R-JOOQ-MAP-*
  8. Пагинация — R-JOOQ-PAG-*
  9. Lock-режимы — R-JOOQ-LCK-*
  10. Транзакции — R-JOOQ-TX-*
  11. View-репозитории — R-JOOQ-VIEW-*
  12. Антипаттерны — сводка R-JOOQ-*-X*

1. Конфигурация codegen

Подробно для человека: Codegen jOOQ из живой схемы PostgreSQL — раскрытие правил R-JOOQ-CFG-* с примерами и контекстом.

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

Подробно для человека: Repository pattern в jOOQ — раскрытие правил R-JOOQ-REPO-* с примерами и контекстом.

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 — Прямой JooqOrderRepository injected в 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

Подробно для человека: DSLContext в jOOQ — Spring-bean, один на ApplicationContext — раскрытие правил R-JOOQ-CTX-* с примерами и контекстом.

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. Построение запросов

Подробно для человека: Построение запросов в jOOQ — раскрытие правил R-JOOQ-QRY-* с примерами и контекстом.

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();
}

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 в jOOQ — eager-fetch одним запросом — раскрытие правил R-JOOQ-MS-* с примерами и контекстом.

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

Подробно для человека: Filter Builders в jOOQ — раскрытие правил R-JOOQ-FLT-* с примерами и контекстом.

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

Подробно для человека: Маппинг record ↔ domain в jOOQ — plain Java, не MapStruct — раскрытие правил R-JOOQ-MAP-* с примерами и контекстом.

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. Пагинация

Подробно для человека: Пагинация в jOOQ — offset, cursor, PaginationView — раскрытие правил R-JOOQ-PAG-* с примерами и контекстом.

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.
  • Отдельный fetchCount query для 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-режимы

Подробно для человека: Lock-режимы в jOOQ — SelectMode, FOR UPDATE, SKIP LOCKED — раскрытие правил R-JOOQ-LCK-* с примерами и контекстом.

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. Транзакции

Подробно для человека: Транзакции в jOOQ — @Transactional на handler'е — раскрытие правил R-JOOQ-TX-* с примерами и контекстом.

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-репозитории

Подробно для человека: View-репозитории в jOOQ — read-проекции отдельно от агрегата — раскрытие правил R-JOOQ-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-X1Jooq<X>Repository implements <X>Repository
Plain SQL dslContext.fetch("...")R-JOOQ-QRY-X1DSL: selectFrom(...)
Конкатенация в conditionR-JOOQ-QRY-X2bind через .eq(value)
record.set...().store() для UPDATER-JOOQ-QRY-X3update(table).set(...)
.fetchOne() где допустим nullR-JOOQ-QRY-X4.fetchOptional()
.fetchCount() > 0R-JOOQ-QRY-X5.fetchExists()
Magic-string alias в multisetR-JOOQ-MS-X1константа из SelectMultisetAliasKeys
Lazy-fetch nested через отдельный запросR-JOOQ-MS-X2multiset() или batch-fetch
Inline ветвящийся WHERER-JOOQ-FLT-X1<X>FilterConditionBuilder + andIfNotNull
POJO/Record возвращается наружуR-JOOQ-MAP-X1mapper → domain
MapStruct с assemble-логикойR-JOOQ-MAP-X3plain Java mapper
DSL.using(...) вручнуюR-JOOQ-CTX-X1Spring-bean DSLContext
LocalDateTime для timestamptzR-JOOQ-CFG-X3OffsetDateTime
setImmutablePojos(true)R-JOOQ-CFG-X2мутабельные POJO
Codegen из xml/ddlR-JOOQ-CFG-X4Liquibase + Testcontainers + introspection
forUpdate() без @TransactionalR-JOOQ-LCK-X1, PG-L-041@Transactional на handler'е
Глобальный optimistic-locking через SettingsR-JOOQ-LCK-X2version-колонка, PG-L-051
@Transactional на репозиторииR-JOOQ-TX-X1на handler'е
Вложенные read-методы в основном <X>RepositoryR-JOOQ-VIEW-X1отдельный <X>ViewRepository
fetchAll() без LIMIT в UI-эндпоинтеR-JOOQ-PAG-X1LIMIT/OFFSET или cursor
JdbcTemplate / JPA / MyBatisR-JOOQ-REPO-X3, BS-17jOOQ

Финальная сводка: правил «Обязательно» — около 50, «Запрещено» — около 25. Findings ревью цитируют конкретный код (R-JOOQ-MS-1, R-JOOQ-LCK-X1).