Опирается на правила:
R-JOOQ-REPO-1…R-JOOQ-REPO-6иR-JOOQ-REPO-X1…R-JOOQ-REPO-X4из jOOQ Style Guide → раздел 2. Repository-pattern. Здесь — те же правила подробно, с примерами и контекстом.
Важно знать
- Repository — это две сущности: интерфейс в
core/domain/repository/и реализацияJooq<X>Repositoryв модулеpersistence/.- На вход и выход — доменные объекты (entity, value object,
PaginationView<T>). Никаких jOOQRecordили 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 / JdbcTemplate | R-JOOQ-REPO-X3 | jOOQ через 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.
Куда дальше
- jOOQ Style Guide → раздел 2. Repository-pattern — нормативные формулировки
R-JOOQ-REPO-*, на которые опирается эта статья. - jOOQ Style Guide → раздел 5. Multiset —
R-JOOQ-MS-*, как собиратьOrderс егоItems одним запросом. - jOOQ Style Guide → раздел 7. Маппинг record ↔ domain —
R-JOOQ-MAP-*, plain Java vs MapStruct. - jOOQ Style Guide → раздел 9. Lock-режимы —
R-JOOQ-LCK-*, что значит каждыйSelectMode. - PostgreSQL: ACID и уровни изоляции — теория за
SelectMode.FOR_UPDATEи почему он часто проще, чем поднимать уровень изоляции.