Опирается на правила: R-JOOQ-REPO-1R-JOOQ-REPO-6 и R-JOOQ-REPO-X1R-JOOQ-REPO-X4 из jOOQ Style Guide → раздел 2. Repository-pattern. Здесь — те же правила подробно, с примерами и контекстом.

Важно знать

  • Repository — это две сущности: интерфейс в core/domain/repository/ и реализация Jooq<X>Repository в модуле persistence/.
  • На вход и выход — доменные объекты (entity, value object, PaginationView<T>). Никаких jOOQ Record или generated POJO наружу.
  • Конструктор через @RequiredArgsConstructor + private final поля. @Autowired на полях — не используем.
  • Все read-методы принимают SelectMode mode — лок выбирается на стороне вызывающего, не зашит в репозитории.
  • В репозитории нет бизнес-логики. Никаких if (order.status == ...), никаких событий, никаких HTTP-вызовов.
  • Generated DAO, Spring Data JDBC, JPA, Hibernate, MyBatis, JdbcTemplate — не используем. jOOQ — единственный путь.
  • Каждый репозиторий покрыт интеграционным тестом против Testcontainers PostgreSQL, без mock'ов DSLContext.

Repository в нашем подходе — это граница между domain-слоем и persistence-слоем. Domain знает «как устроен Order», persistence знает «как достать Order из PostgreSQL». Repository — единственное место, где эти два знания встречаются. Всё, что выходит наружу из persistence, уже превращено в доменный объект; всё, что приходит в persistence, — доменный объект. jOOQ-типы внутрь Domain не проникают, бизнес-логика в persistence не проникает.

Это раскрытие раздела 2 jOOQ Style Guide. В гайде — формулировки правил с кодами; здесь — почему правила именно такие и как их применять.

Доменный интерфейс отдельно от реализации

R-JOOQ-REPO-1: интерфейс в core/, имплементация в persistence/.

// 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;
    // методы интерфейса
}

Почему именно так:

  • Domain-модуль не зависит от jOOQ — в core/ нет ни DSLContext, ни generated классов, ни org.jooq.* в импортах. Это позволяет тестировать domain в изоляции и менять persistence-технологию без переписывания бизнес-логики (теоретически; на практике мы её не меняем, но архитектурный ArchUnit-тест на отсутствие jOOQ в core/ дисциплинирует).
  • Имя Jooq<X>Repository — конвенция, по которой видно, какой технологией реализован репозиторий. Если завтра появится RedisOrderRepository (например, для cache-aside), они не путаются.
  • Use-case handler инжектит OrderRepository, не JooqOrderRepository (это R-JOOQ-REPO-X2). Это и есть весь смысл интерфейса — иначе можно было бы обойтись одним классом.

Конструкторное внедрение

R-JOOQ-REPO-2: @RequiredArgsConstructor, private final, без @Autowired на полях.

@Repository
@RequiredArgsConstructor
public class JooqProductRepository implements ProductRepository {
    private final DSLContext dslContext;
    private final ProductDomainRecordMapper mapper;
}

Что это даёт:

  • Иммутабельность зависимостей — после конструирования бин не меняется, нет соблазна setDslContext в тестах.
  • Явный список зависимостей — конструктор виден целиком, можно за секунду понять, что нужно мокать в unit-тесте (хотя для репозитория unit-тестов мы и не пишем, см. R-JOOQ-REPO-6).
  • Невозможность создать невалидный бинfinal поля без значения = compile-time error. Field injection такую проверку обходит.

Field injection (@Autowired private DSLContext dslContext) выглядит короче, но: невозможно создать инстанс из теста без рефлексии, нельзя сделать final, проблемы с круговыми зависимостями обнаруживаются в рантайме, а не на старте.

Public методы — только доменные типы

R-JOOQ-REPO-3: на входе и выходе — domain entity, value object, PaginationView<T>. jOOQ Record и generated POJO остаются внутри.

// ХОРОШО
Optional<Order> findById(Long id, SelectMode mode);
PaginationView<Order> findAll(OrderFilter filter, int page, int size);

// ПЛОХО
Optional<OrderPojo> findById(Long id);          // generated POJO протёк наружу
Result<Record> findRaw(Condition cond);         // jOOQ-тип в публичной сигнатуре
List<OrderRecord> findActive();                 // generated record наружу

Зачем это:

  • Handler не зависит от persistence-деталей — он работает с Order, а не с OrderPojo. Меняем generation strategy, перенастраиваем forcedTypes — handler не ломается.
  • Domain-методы есть только у доменных объектовorder.canBeCancelled() живёт на Order, не на OrderPojo. Если бы handler работал с POJO, бизнес-проверки растеклись бы по handler'ам.
  • Тестируемость domain'а — domain unit-тесты конструируют Order через builder/factory, без походов в PostgreSQL.

Маппинг POJO → domain происходит в OrderDomainRecordMapper. Это отдельный класс, тоже в persistence/ (см. раздел 7 гайда — R-JOOQ-MAP-*, об этом будет отдельная статья).

SelectMode на каждом read-методе

R-JOOQ-REPO-4: все public read-методы принимают SelectMode mode.

public interface OrderRepository {
    Optional<Order> findById(Long id, SelectMode mode);
    PaginationView<Order> findAll(OrderFilter filter, int page, int size, SelectMode mode);
}

SelectMode — enum: NO_LOCK, FOR_UPDATE, FOR_UPDATE_SKIP_LOCKED, FOR_SHARE. Вызывающий явно указывает, какой уровень блокировки ему нужен.

Почему параметр, а не два метода findById / findByIdForUpdate:

  • Меньше методов — для агрегата с 5 read-операциями и 4 lock-режимами было бы 20 методов в интерфейсе.
  • Лок — это про use-case, не про репозиторий — read для view возьмёт NO_LOCK, command-handler перед апдейтом возьмёт FOR_UPDATE. Репозиторий не угадывает, он принимает решение от handler'а.
  • Виден контракт — глядя на handler, можно сразу сказать, какие блокировки он берёт. Скрывать это «внутри метода с говорящим именем» — спрятать важную деталь.
// Command handler
Order order = orderRepository.findById(id, SelectMode.FOR_UPDATE)
    .orElseThrow(() -> new OrderNotFoundException(id));
order.cancel();
orderRepository.save(order);

// Query handler
Order order = orderRepository.findById(id, SelectMode.NO_LOCK)
    .orElseThrow(() -> new OrderNotFoundException(id));
return orderMapper.toView(order);

См. раздел 9 гайда (R-JOOQ-LCK-*) о механике lock-режимов.

Сложное — в private методы того же класса

R-JOOQ-REPO-5: multiset-сборка, сортировка, лок — выносим в private хелперы.

@Repository
@RequiredArgsConstructor
public class JooqOrderRepository implements OrderRepository {
    private final DSLContext dslContext;
    private final OrderDomainRecordMapper mapper;

    @Override
    public PaginationView<Order> findAll(OrderFilter filter, int page, int size, SelectMode mode) {
        return dslContext
            .select(ORDER.fields())
            .select(buildItemsMultiset())
            .from(ORDER)
            .where(conditionBuilder.build(filter))
            .orderBy(toSortFields(filter.sort()))
            .pipe(applyLock(mode))
            .limit(size).offset(page * size)
            .fetch()
            .map(mapper::toDomain)
            .pipe(records -> PaginationView.of(records, page, size, /* total */));
    }

    private Field<Result<Record3<Long, String, BigDecimal>>> buildItemsMultiset() { /* ... */ }
    private List<SortField<?>> toSortFields(Sort<OrderField> sort) { /* ... */ }
    private SelectQuery<?> applyLock(SelectMode mode) { /* ... */ }
}

Public метод читается линейно сверху вниз, как SQL: select → from → where → order → lock → fetch → map. Без вложенных лямбд на пять уровней. Детали — рядом, в том же классе, не разбросаны по utility-классам.

Не отдельные классы для OrderMultisetBuilder, OrderSortMapper и т.д. — пока они нужны только в JooqOrderRepository, они должны жить рядом. Когда (если) понадобятся в другом репозитории — выносим. Раньше — преждевременная абстракция.

Бизнес-логики в репозитории нет

R-JOOQ-REPO-X4: ни if (order.status == ...), ни вызовов внешних сервисов, ни публикации событий.

// ПЛОХО
public void save(Order order) {
    if (order.getStatus() == OrderStatus.CANCELLED) {
        notificationClient.send("Order cancelled");   // ← внешний вызов
    }
    if (order.getTotal().compareTo(BigDecimal.ZERO) <= 0) {
        throw new IllegalStateException("Total must be positive");  // ← бизнес-инвариант
    }
    dslContext.insertInto(ORDER)...
}

Что не так:

  • Notification-вызов внутри транзакции БД — выполнится даже если транзакция потом откатится. Это классическая дыра «отправили email, потом откатили заказ». Сюда нужен Outbox-pattern, и точно не такая реализация.
  • Бизнес-инвариант в персистентном слое — проверка total > 0 должна быть в конструкторе/фабрике Order. К моменту, когда Order дошёл до репозитория, он уже валиден. Если в репозитории нужны такие проверки — значит, доменная модель анемична.

Что разрешено в репозитории: формирование SQL, проставление created_at/updated_at, маппинг record↔domain, метрики jOOQ-уровня. Всё.

Запретный список

R-JOOQ-REPO-X1..X4 — четыре пункта, которые ловит ucp-jooq-review:

АнтипаттернПравилоЧто взамен
Generated *Dao в репозиторииR-JOOQ-REPO-X1Ручной Jooq<X>Repository implements <X>Repository
Handler инжектит JooqOrderRepository напрямуюR-JOOQ-REPO-X2Инжектим domain-интерфейс OrderRepository
Spring Data JDBC / JPA / Hibernate / MyBatis / JdbcTemplateR-JOOQ-REPO-X3jOOQ через DSLContext, единственная технология
Бизнес-логика в репозиторииR-JOOQ-REPO-X4Логика в доменных методах, репозиторий — только query/persistence

R-JOOQ-REPO-X3 — самый часто обсуждаемый. «У нас же есть Spring Data, зачем ещё jOOQ?» Ответ короткий: типобезопасность + читаемые SQL-запросы + multiset для nested-fetch + контроль над генерируемым SQL. Spring Data JDBC хорош для CRUD по одной таблице; как только появляется JOIN или нетривиальная агрегация — пишется plain SQL в @Query, и преимущество уходит. jOOQ остаётся типобезопасным в любых сценариях. См. BS-17 в Spring Bootstrap для подробной мотивации выбора стека.

Тестируем против настоящего PostgreSQL

R-JOOQ-REPO-6: интеграционный тест против Testcontainers, без mock'ов DSLContext.

@SpringBootTest
class JooqOrderRepositoryTest extends BaseIntegrationTest {

    @Autowired private OrderRepository repository;
    @Autowired private DatabasePreparer dbPreparer;

    @Test
    void findById_returnsOrderWithItems() {
        Long orderId = dbPreparer.givenOrder().withItem("p-1", 2).withItem("p-2", 1).insert();

        Order found = repository.findById(orderId, SelectMode.NO_LOCK).orElseThrow();

        assertThat(found.id()).isEqualTo(orderId);
        assertThat(found.items()).hasSize(2);
    }
}

Зачем не mock'и:

  • jOOQ-запрос — это SQL — мокать DSLContext означает «доверять, что наш SQL валиден». Это и тестируем, не имеет смысла обходить.
  • Multiset, lock-режимы, generated columns, FK-constraints — всё это работает только в живой БД. Mock'и пропустят 80% реальных багов.
  • Testcontainers подняли один раз, разделяются между тестами — overhead на сборку <30 секунд для всего модуля, не на тест.

Подробно про test strategy — в Test Strategy Style Guide и парном скилле ucp-test-design.

Куда дальше