В стеке 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-слой обслуживает три разных потребителя:
- Командные хендлеры (
UseCaseCommand→UseCaseHandler) — загружают агрегат целиком, выполняют доменную операцию, сохраняют. Транзакция — на хендлере. - Запросные хендлеры (
UseCaseQuery) — читают проекции, не агрегаты. Структура read-DTO продиктована UI/API, а не доменом. - 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 + CQRS | JPA/Hibernate | MyBatis | Spring Data | jOOQ |
|---|---|---|---|---|
| Явность 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.