В стеке Use Case Pattern + DDD + CQRS persistence-слой — только jOOQ. Не Hibernate/JPA, не MyBatis, не Spring Data JDBC. Это командное правило (BS-17 в Spring Bootstrap), и оно выглядит догматичным до тех пор, пока не разбираешь, какой работы persistence-инструмент должен делать в нашем паттерне — и какой работы он делать не должен.

Эта страница — обоснование выбора. Если ищешь, как писать на jOOQ, открой jOOQ Style Guide.


TL;DR

  • jOOQ даёт типобезопасный SQL DSL над сгенерированной из живой PG-схемы моделью. SQL остаётся явным, агрегат собирает команда, transaction-границы определяет use-case handler.
  • Hibernate/JPA скрывает SQL и навязывает change-tracking, lazy-loading и кеши — это конфликтует с DDD-агрегатом (он сам управляет своей целостностью) и CQRS (read-projection ≠ агрегат).
  • MyBatis оставляет SQL явным, но кладёт его в xml/аннотации — теряется композируемость, IDE-навигация, проверки в compile-time.
  • Spring Data JDBC / JPA генерирует репозитории по naming-magic. Для тривиального CRUD ок, но в момент любого нестандартного запроса всё равно падаешь в plain SQL или в @Query-строки.

В нашем паттерне persistence-слой — тонкий, явный, без скрытого state. jOOQ — единственный из четырёх, где это получается естественно.


Контекст: что от persistence-слоя ждёт UCP + DDD + CQRS

Прежде чем сравнивать инструменты, надо договориться о требованиях. В нашем паттерне persistence-слой обслуживает три разных потребителя:

  1. Командные хендлеры (UseCaseCommandUseCaseHandler) — загружают агрегат целиком, выполняют доменную операцию, сохраняют. Транзакция — на хендлере.
  2. Запросные хендлеры (UseCaseQuery) — читают проекции, не агрегаты. Структура read-DTO продиктована UI/API, а не доменом.
  3. Schedulers / outbox-relay — батчевые операции с pessimistic-lock'ом (FOR UPDATE SKIP LOCKED).

Эти потребители требуют от persistence:

  • Явность SQL. В DDD агрегат сам отвечает за свою целостность. Это значит репозиторий грузит и сохраняет весь агрегат — никаких lazy-полей, никакой тихой подгрузки в момент чтения геттера. Multiset/JOIN — сразу, явно, в одном запросе.
  • Контроль над границами агрегата. Когда мы загружаем Order, мы должны загрузить именно Order со всеми OrderItems и Payment — не больше и не меньше. ORM, который сам решает, что подгрузить, ломает эту границу.
  • Свобода read-projection. В CQRS read-моделей много, и они не повторяют форму агрегата. Persistence-инструмент должен не мешать делать произвольные SELECT с группировками, оконными функциями и multiset для nested-коллекций.
  • Типобезопасность. Generated классы из живой схемы (Liquibase → PG → codegen). Колонка переименована — компилятор показывает все места, где она используется. Не runtime, не миграционная драма.
  • Локи как первоклассный концепт. FOR UPDATE, SKIP LOCKED, NOWAIT — синтаксис должен быть в DSL, а не «зашит в @Lock(LockModeType.PESSIMISTIC_WRITE)», который маппится в неочевидные LOCK TABLE на разных диалектах.
  • Доменные enum'ы как один источник правды. Java-enum в core/, PG-enum в схеме — мостик между ними строится автоматически (<enumConverter>true</enumConverter> в codegen).

Имея этот список, посмотрим, где альтернативы рвутся.


Hibernate / JPA — почему мы не используем

Hibernate/JPA построен под другую парадигму: object-graph с identity map, lazy-loading, dirty-checking. Это удобно в active record / anemic-domain мире. В DDD/CQRS — конфликтует:

1. Lazy-loading ломает границу агрегата

JPA позволяет писать order.getItems().forEach(...) где-нибудь в use-case, и Hibernate тихо сходит в БД догружать. Это удобно для прототипа и катастрофа в продакшне:

  • N+1 запросы — невидимы в коде, видимы только под нагрузкой.
  • LazyInitializationException — потому что сессия закрылась, а коллекция не была инициализирована.
  • Граница агрегата размывается: «а где в коде на самом деле читается Items?» — ответ распылён по всем хендлерам.

В DDD граница агрегата — это то, что грузим вместе одним запросом. Lazy ломает этот контракт.

2. Dirty-checking — скрытый магический update

JPA отслеживает изменения и делает UPDATE в flush() автоматически. В UCP-хендлере:

@Transactional
public Order handle(ConfirmOrderCommand cmd) {
    Order order = orderRepository.findById(cmd.orderId());
    order.confirm();
    return order;   // ← UPDATE втихую улетит на flush
}

Что именно обновилось? Какие поля? В каком порядке? Какие триггеры сработают? Чтобы понять — надо включать hibernate.show_sql или smell-test через EntityManager.unwrap. У нас репозиторий явный: orderRepository.save(order) — сгенерированный UPDATE с явным WHERE и SET.

3. Кеши первого/второго уровня — невидимое состояние

Identity map в EntityManager гарантирует, что один и тот же ID в одной транзакции вернёт один и тот же объект. Это полезно… когда полезно, и неприятно, когда из ниоткуда возникает «а почему мой UPDATE не виден в этом же EM». Второй уровень кеша вообще усложняет debug — оптимистичный лок на cached entity ведёт себя неинтуитивно.

В нашем стеке — простое правило: грузим, читаем, сохраняем. Никакого identity map. Если нужно дважды загрузить — это два запроса.

4. Read-projection через JPQL — половинчато

CQRS-проекции в JPA либо тащат entity-graph (медленно, не та форма), либо пишутся через EntityManager.createNativeQuery (теряется типобезопасность, маппинг руками), либо через @Query (строка, проверка в runtime).

В jOOQ это тот же DSL: select(...) с любой формой проекции и multiset для nested-коллекций. Один инструмент — два сценария (запись агрегата и чтение проекции), без переключения парадигмы.

5. JPA-схема vs реальная PG-схема — два источника правды

@Entity-маппинг и DDL расходятся. Liquibase-миграция добавила колонку → JPA не знает, пока не обновишь сущность. Поменял nullable → JPA не отслеживает. У jOOQ codegen работает из живой PG-схемы (через Testcontainers + Liquibase) — единственный источник правды.


MyBatis — почему не подходит

MyBatis сохраняет SQL явным — это плюс. Но цена:

  • SQL в xml/аннотациях. Композиция запросов через <if>/<foreach> теряет читаемость. Вынос предиката в отдельный фрагмент — лишняя церемония. В jOOQ предикат — это Condition-объект, его можно собрать в Java-методе и передать.
  • Нет compile-time проверки. Опечатка в имени колонки в xml — runtime-ошибка. У jOOQ generated-классы дают Tables.ORDERS.STATUS — IDE кидает ошибку при удалении колонки.
  • Маппинг руками. <resultMap> для каждого DTO. Для read-проекций (CQRS) это сотни ResultMap'ов и их поддержки.
  • Нет multiset. Чтобы загрузить Order с OrderItems одним запросом — пишешь хитрый JOIN + ResultMap с nested. В jOOQ — multiset(select...) и Records.mapping(...) или явный maper.

MyBatis ок там, где SQL пишет DBA, а Java-код только дёргает. У нас SQL пишут разработчики, и они это делают на Java — multiset, case_, exists и т.п. — как код, не как xml.


Spring Data JDBC / Spring Data JPA — почему не подходит

Spring Data — самый соблазнительный вариант: «нагенерируем репозитории по naming convention, и не надо писать код». Для тривиального CRUD действительно работает. Дальше — швы:

  • Naming-magic. findByCustomerIdAndStatusInAndCreatedAtBetween — пока поля три. Когда фильтр сложный (5+ полей, EXISTS на связанной таблице, опциональные предикаты) — переходишь на @Query("SELECT ...") или Specification API. И всё, преимущества генерации улетели.
  • Specification API (JPA) — попытка собрать предикат через Criteria. Многословно, сложно читается, теряет типобезопасность из generated-классов.
  • Repository как наследник CrudRepository — насильно тянет за собой findAll(), count(), delete() и подобное, даже если в домене они не имеют смысла. Доменный репозиторий должен иметь только те методы, которые нужны хендлерам.
  • Spring Data JDBC позиционируется как «JPA без магии» — но всё равно даёт agg-mapping (грузит агрегат целиком), и любой нестандартный запрос — @Query или native SQL.

В UCP репозиторий — это хендкрафт под доменный контракт (OrderRepository implements ... в core/), JooqOrderRepository в persistence/ его реализует. Spring Data добавляет слой, который мы потом выкидываем при первой нестандартной задаче.


jOOQ — почему получается естественно

Что jOOQ даёт, что закрывает наши требования:

1. Типобезопасный DSL над живой схемой

Codegen из Testcontainers PostgreSQL + Liquibase — single source of truth. Колонка/тип/enum на стороне БД → generated класс в Java → IDE-навигация и compile-time проверка. Подробности — R-JOOQ-CFG-1 в jOOQ Style Guide.

2. Multiset для CQRS read-models

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)

Это один SQL-запрос, который грузит Order со всеми Tickets и для каждого тикета — все Insurances. Никаких N+1, никаких проксей. Идеально под DDD-агрегат («грузим целиком») и под CQRS (форма произвольная). См. R-JOOQ-MS-3.

3. Явные транзакции через Spring @Transactional

@Transactional ставится на хендлер (R-JOOQ-TX-1). Репозиторий не знает о границах — он просто исполняет запросы в текущем контексте. Граница транзакции = граница use-case. Это и есть DDD-определение transaction boundary.

4. SelectMode как доменный концепт

public enum SelectMode { NO_LOCK, FOR_UPDATE, FOR_UPDATE_SKIP_LOCKED }

FOR UPDATE — не infrastructure-деталь, скрытая в @Lock(...)-аннотации. Это явный аргумент репозиторного метода: «мне нужна эта строка с локом, потому что я буду менять её через 200мс в этой же транзакции». Доменный язык про concurrency. См. R-JOOQ-LCK-1.

5. Generated DAO мы НЕ используем

jOOQ умеет генерировать DAO-классы (OrdersDao с findById/insert/update/delete). Мы это явно отключаем (R-JOOQ-CFG-X1). Причина: domain-репозиторий — interface в core/, контракт домена, не «универсальный CRUD». jOOQ-имплементация в persistence/ реализует только то, что доменный контракт требует.

6. Доменные enum'ы — codegen'ом

PG-enum + Java-enum + <enumConverter>true</enumConverter> в codegen-конфиге = автоматическая конверсия. Один источник правды на оба языка. См. R-JOOQ-CFG-6.


Цена выбора

Не бесплатно. На что соглашаемся:

  • Codegen на каждый build. ./gradlew compileJava поднимает Testcontainers PG, накатывает Liquibase, делает introspection. На холодную — 30–60s. На тёплую (cache) — 5–10s. Работает в CI, локально требует Docker.
  • Repository пишем руками. Контракт интерфейса, Jooq-имплементация, mapper, filter-builder — всё руками. Это видно как «много кода» по сравнению с extends JpaRepository<Order, Long>. Окупается на третьем нестандартном запросе.
  • Нет ленивой загрузки. Это плюс в нашем стеке — границы агрегата явные, N+1 невозможен незаметно. Но требует дисциплины: при загрузке агрегата сразу описать multiset для всего, что нужно.
  • Сложнее для команды без SQL-опыта. jOOQ — это SQL на Java. Если команда не понимает, чем EXISTS отличается от JOIN, jOOQ не спасёт. Hibernate тоже не спасёт — он просто скроет проблему до прода.

Итог

Требование UCP + DDD + CQRSJPA/HibernateMyBatisSpring DatajOOQ
Явность SQLнет (lazy, dirty)да (xml)нет (naming-magic)да (Java DSL)
Контроль границы агрегатаплохо (lazy ломает)дасреднеда
Свобода read-projectionплохо (entity-graph)средне (resultMap)плохо (Specification)да (multiset)
Compile-time проверкичастичнонет (xml)частичнода (codegen)
Локи как первоклассный концептсредне (@Lock)да (raw SQL)плохода (DSL)
Доменные enum как single sourceплохосреднеплохода (codegen)
Цена обучениясредняянизкаянизкаясредняя–высокая

Для UCP + DDD + CQRS-стека jOOQ — единственный, где все требования закрыты одним инструментом без обхода типа «native query» или «specification API».

Хочешь видеть, как jOOQ применяется в коде — открой jOOQ Style Guide с правилами R-JOOQ-*. Хочешь видеть, на каком DDD-каркасе он работает — DDD: тактические паттерны и Use Case Pattern.