Опирается на правила:
R-REP-1…R-REP-5иR-REP-X1…R-REP-X3из DDD Tactical Style Guide → раздел 5. Repository.
Важно знать
- Repository — это port между доменом и persistence. Интерфейс — в
core/<bc>/domain/repository/, реализация — вadapter/out/postgres/илиpersistence/модуле.- Один репозиторий — один корень агрегата.
OrderRepositoryподнимает и сохраняетOrderцеликом (со всемиOrderItems).OrderItemRepository— антипаттерн.- Сигнатуры — только в доменных типах:
Order,Optional<Order>,PaginationView<Order>. Никаких jOOQRecord, generatedOrderPojo,Page<OrderEntity>.saveатомарно сохраняет состояние + публикует собранныеregisterEvent-ы черезDomainEventPublisher.publishAll(...), после чего вызываетclearDomainEvents()на корне.- Методы названы по бизнес-смыслу:
findActiveByCustomerId, неselectFromOrders.findById, неgetOne.- Спецификации генерируют SQL — не в Repository. Для сложных read-сценариев — read-model и отдельный QueryRepository.
- Интеграционный тест против настоящей PG (Testcontainers), не mock'и
DSLContext.
Repository — единственный объект, который преобразует «агрегат как domain-сущность» в «строки в БД» и обратно. Доменный код о SQL не знает. SQL-код о бизнес-правилах не знает. На стыке — Repository. Раскрытие раздела 5 гайда.
Интерфейс в core, реализация в адаптере
R-REP-1, R-REP-2: интерфейс наследует AggregateRepository<T, ID> и живёт в core/<bc>/domain/repository/. Реализация — в outbound-адаптере (adapter/out/postgres/, в проектах с multi-module gradle — отдельный модуль persistence/).
// core/order/domain/repository/OrderRepository.java
public interface OrderRepository extends AggregateRepository<Order, OrderId> {
Optional<Order> findById(OrderId id, SelectMode mode);
PaginationView<Order> findActiveByCustomerId(CustomerId customerId, Pageable pageable);
void save(Order order);
}
// persistence/order/JooqOrderRepository.java
@Repository
@RequiredArgsConstructor
class JooqOrderRepository implements OrderRepository {
private final DSLContext dsl;
private final OrderDomainRecordMapper mapper;
private final DomainEventPublisher publisher;
@Override
public Optional<Order> findById(OrderId id, SelectMode mode) { /* ... */ }
@Override
public PaginationView<Order> findActiveByCustomerId(...) { /* ... */ }
@Override
public void save(Order order) {
dsl.transaction(cfg -> {
mapper.upsert(cfg.dsl(), order);
publisher.publishAll(order.getDomainEvents());
});
order.clearDomainEvents();
}
}
Что даёт разделение:
core/не зависит от jOOQ. Доменный код можно собирать в отдельный jar без транзитивных зависимостей persistence-стека. ArchUnit-тест на отсутствиеorg.jooq.*вcore/дисциплинирует разработчиков.- Реализацию можно заменить. В тестах — in-memory
Map<OrderId, Order>. В bench-сценарии — read через Redis. Контракт стабилен. - Имя класса говорит о технологии.
JooqOrderRepositoryявно — на jOOQ. Если завтра появитсяRedisOrderRepositoryдля cache-aside илиS3OrderArchiveRepository, имена не конфликтуют.
Подробно про jOOQ-реализацию — в Repository pattern в jOOQ.
Один репозиторий = один агрегат
R-REP-3: репозиторий обслуживает корень агрегата, не отдельные внутренние Entity и не «универсальные данные».
// ХОРОШО
public interface OrderRepository { /* поднимает/сохраняет Order целиком */ }
public interface CustomerRepository { /* поднимает/сохраняет Customer */ }
// ПЛОХО
public interface OrderItemRepository { // ← OrderItem — это не корень, а часть Order
OrderItem findById(OrderItemId id);
void save(OrderItem item);
}
public interface GenericRepository<T, ID> { // ← универсальный = без границ
T findOne(ID id);
void persist(T entity);
}
Зачем строгое соответствие:
- Атомарность сохранения агрегата. Когда
Orderменяет состояние (добавили item, поменяли total), всё это сохраняется однимOrderRepository.save(order). Не «сохрани Order, потом отдельно сохрани item-ы». - События публикуются один раз. События, накопленные в корне через
registerEvent, публикуются после сохранения. Если бы был отдельныйOrderItemRepository, события на агрегате пришлось бы публиковать вручную в сервисе — этоR-AGG-X4(события вне корня). - Транзакционная граница совпадает с границей агрегата. Один
@Transactionaluse-case — одинrepository.save.
Если действительно нужен запрос «найти item по id без поднятия order» — это read-сценарий, делается через ViewRepository на query-side (см. CQRS Style Guide и view repositories в jOOQ). Не через мутирующий Repository.
Сигнатуры — только доменные типы
R-REP-X1: на входе и выходе — Entity, VO, Optional<T>, PaginationView<T>. jOOQ Record, generated POJO, Spring Page — не наружу.
// ХОРОШО
Optional<Order> findById(OrderId id, SelectMode mode);
PaginationView<Order> findActiveByCustomerId(CustomerId customerId, Pageable pageable);
// ПЛОХО
OrderPojo findById(Long id); // ← generated POJO протёк
List<OrderRecord> findActiveByCustomerId(...); // ← jOOQ-record в публичной сигнатуре
Page<OrderEntity> page(...); // ← Spring Data Page тоже протекает
Что даёт ограничение:
- Handler работает с domain-моделью. Вызывает
order.confirm(), неorderPojo.setStatus(...). Бизнес-методы живут на доменных объектах, не на POJO. - Меняем generation strategy — не ломаем handler. Перенастройка
forcedTypes, переименование колонок в БД — изменения локальны дляJooqOrderRepository+OrderDomainRecordMapper. - Тестируемость. Domain unit-тесты конструируют
Orderнапрямую через factory/builder, без mock-ов БД.
Маппинг record/POJO ↔ Order делается в отдельном классе — OrderDomainRecordMapper. Тоже в persistence-модуле. Подробно — в Маппинг record ↔ domain.
Save: агрегат целиком + публикация событий + clear
R-REP-4: один save(Order order) атомарно делает три вещи: пишет состояние, публикует доменные события, очищает их на корне.
@Override
public void save(Order order) {
dsl.transaction(cfg -> {
mapper.upsert(cfg.dsl(), order); // 1. состояние агрегата + всех его Entity
publisher.publishAll(order.getDomainEvents()); // 2. события в outbox (внутри транзакции)
});
order.clearDomainEvents(); // 3. чистим список событий на корне
}
Почему всё в одном методе:
- Атомарность. Состояние + outbox-event — одна транзакция. Невозможно сохранить агрегат и потерять событие, либо опубликовать событие, которого не было.
- Корень не знает про публикацию. Агрегат вызывает
registerEvent— это всё. Доставкой занимается репозиторий. - Clear после успешного commit. Если transaction откатилась —
clearDomainEventsне вызывается, повторныйsaveснова попробует опубликовать.
Подробно про публикацию событий и outbox — в Domain Event и Kafka Style Guide.
Методы — в терминах домена
R-REP-5: метод называется по тому, что ищем в бизнес-смысле, не по тому, как это устроено в БД.
// ХОРОШО
Optional<Order> findById(OrderId id, SelectMode mode);
PaginationView<Order> findActiveByCustomerId(CustomerId customerId, Pageable pageable);
List<Order> findExpiredReservations(Instant now);
// ПЛОХО
List<Order> selectFromOrdersWhereStatusEq(String status); // ← SQL-термины
Order getOneByPrimaryKey(Long id); // ← про БД
List<Order> queryWithJoin(Long customerId); // ← деталь реализации
Что даёт правильное имя:
- Handler читается как бизнес-история.
orderRepository.findActiveByCustomerId(customerId, page)— понятно сразу.selectFromOrdersWhereStatusEq("ACTIVE")— нужно лезть в код метода. - Refactor реализации не ломает имя. Сменили подход «1 запрос с join» → «multiset» — имя
findActiveByCustomerIdосталось. МетодqueryWithJoinпришлось бы переименовывать. - Domain-словарь живёт в коде.
findExpiredReservationsсоответствует бизнес-понятию «expired reservation», которое есть и в спеке, и в обсуждениях.
R-REP-X2: методы вроде updateStatusInDb или incrementCounter — деталь хранения, не доменная операция. Их нет: статус меняется через order.cancel(), репозиторий сохраняет агрегат целиком.
Specification ≠ Repository
R-REP-X3: Specification<T> — это доменное правило (см. соответствующую статью), не построитель SQL. Если Specification.toPredicate собирает jOOQ Condition — это сразу нарушение core/ без зависимостей и смешение Repository с Query Side.
// ПЛОХО — Specification генерирует SQL
public interface OrderRepository {
List<Order> findAll(Specification<Order> spec); // ← spec.toJooqCondition(...) где-то внутри
}
// ХОРОШО — для сложных read-сценариев отдельный QueryRepository / ViewRepository
public interface OrderQueryRepository {
PaginationView<OrderView> findByFilter(OrderFilter filter, Pageable pageable);
}
Specification остаётся в домене: isSatisfiedBy(Order o) — проверяет правило in-memory, не строит SQL. Для построения SQL — OrderFilterConditionBuilder в persistence/, явный, не маскирующийся под доменное правило (см. filter builders в jOOQ).
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Возврат Record, *Pojo, Page<...Entity> наружу | R-REP-X1 | Доменные типы: Order, Optional<Order>, PaginationView<Order> |
Метод, специфичный для одной таблицы (updateStatusInDb) | R-REP-X2 | Изменение через метод агрегата + save(order) |
Specification в Repository, генерирующая SQL | R-REP-X3 | Read через ViewRepository, фильтр через FilterConditionBuilder |
| Один репозиторий на несколько корней | R-REP-3 | Один Repository = один корень |
| Имя метода в SQL-терминах | R-REP-5 | Имя в терминах домена |
Куда дальше
- DDD Tactical → раздел 5. Repository — нормативные формулировки
R-REP-*. - Aggregate Root — что именно
saveсохраняет (агрегат целиком + события). - Domain Event — публикация + clear после save.
- Repository pattern в jOOQ — подробно о реализации.
- View Repositories в jOOQ — отдельный путь для read-сценариев.
- CQRS Style Guide — read-model и query-side.