Опирается на правила: R-REP-1R-REP-5 и R-REP-X1R-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>. Никаких jOOQ Record, generated OrderPojo, 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 (события вне корня).
  • Транзакционная граница совпадает с границей агрегата. Один @Transactional use-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, генерирующая SQLR-REP-X3Read через ViewRepository, фильтр через FilterConditionBuilder
Один репозиторий на несколько корнейR-REP-3Один Repository = один корень
Имя метода в SQL-терминахR-REP-5Имя в терминах домена

Куда дальше