Опирается на правила: R-JOOQ-VIEW-1R-JOOQ-VIEW-3 и R-JOOQ-VIEW-X1 из jOOQ Style Guide → раздел 11. View-репозитории.

Важно знать

  • Если read-проекция отличается от агрегата (флаговый список, сводка, экспорт), выделяем отдельный <X>ViewRepository, не перегружаем <X>Repository.
  • View-репозиторий читает только нужные поля, без full multiset, без heavy joins, без сборки агрегата.
  • Возвращает domain-friendly read-DTO (OrderSummary, OrderRow), не агрегат Order.
  • Read-DTO — record в core/domain/repository/view/. Иммутабельный, без бизнес-логики.
  • Не путать с CQRS — здесь нет write-side / read-side split на уровне БД, это просто два интерфейса вокруг одной таблицы.
  • Запрещено: перегружать <X>Repository методами вида findSummaries, findForExport — смешение write-aggregate и read-projection контрактов.

«У нас на витрине нужен список заказов с краткой инфой — id, статус, сумма, дата. Зачем при этом тянуть Order со всеми его Ticket-ами и Insurance-ами через multiset?» Это типичная история, когда стандартный OrderRepository.findAll() слишком тяжёлый для UI-сценария. Решение — отдельный view-репозиторий с лёгкими read-DTO. Раскрытие правил R-JOOQ-VIEW-* ниже.

Отдельный интерфейс рядом с репозиторием

R-JOOQ-VIEW-1: вводим <X>ViewRepository рядом с <X>Repository.

// core/domain/repository/

public interface OrderRepository {                  // CRUD агрегата
    Optional<Order> findById(Long id, SelectMode mode);
    PaginationView<Order> findAll(OrderFilter filter, int page, int size, SelectMode mode);
    void save(Order order);
}

public interface OrderViewRepository {              // оптимизированные read-проекции
    PaginationView<OrderSummary> findSummaries(OrderFilter filter, int page, int size);
    List<OrderRow> findExport(OrderFilter filter);
    OrderDashboardStats fetchDashboardStats(OffsetDateTime from, OffsetDateTime to);
}

Два разных контракта:

  • OrderRepository — про write-агрегат. findById нужен command-handler'у перед UPDATE; save — единственный способ записи. Все методы работают с полным Order со всеми вложенными частями.
  • OrderViewRepository — про read-проекции. Каждый метод — под конкретный read-сценарий: страница на витрине, экспорт в CSV, дашборд аналитики. Возвращает компактные read-DTO.

Два класса реализации (рядом, в одном persistence-модуле):

@Repository class JooqOrderRepository implements OrderRepository { /* ... */ }
@Repository class JooqOrderViewRepository implements OrderViewRepository { /* ... */ }

В одной транзакции query-handler инжектит OrderViewRepository, command-handler — OrderRepository. По именам сразу видно «эта операция читает плотно» vs «эта пишет в агрегат».

Read-DTO в core

R-JOOQ-VIEW-3: read-DTO — record в core/.

// core/domain/repository/view/OrderSummary.java
public record OrderSummary(
    Long id,
    OrderStatus status,
    BigDecimal totalAmount,
    String customerName,
    int itemsCount,
    OffsetDateTime createdAt
) {}

// core/domain/repository/view/OrderRow.java — для CSV-экспорта
public record OrderRow(
    Long id,
    String status,
    BigDecimal total,
    String customerEmail,
    String shippingCity,
    OffsetDateTime createdAt
) {}

Что важно:

  • Иммутабельные record'ы. Конструктор задаёт все поля сразу, getter'ы автогенерируются.
  • Domain-типы внутри (OrderStatus, OffsetDateTime), не jOOQ-generated и не строки. Иногда DTO имеет смысл денормализовать дальше (например, status как String для CSV), но это — продуктовое решение.
  • Никакой бизнес-логики. OrderSummary.canBeCancelled() — НЕ. Бизнес-логика живёт на агрегате Order. View-DTO — носитель данных, не активный объект.
  • Поля под сценарий. OrderSummary для витрины — короткий. OrderRow для экспорта — поля «как для аналитика», часто другие. Не пытаемся сделать один универсальный DTO для всех read-сценариев — это разрушит идею view.

Расположение: core/domain/repository/view/<entity>.java или core/dto/view/<entity>.java — оба варианта приемлемы, главное чтобы не в persistence/.

View читает только нужное

R-JOOQ-VIEW-2: read-методы — без multiset, без heavy joins, без сборки агрегата.

@Repository
@RequiredArgsConstructor
public class JooqOrderViewRepository implements OrderViewRepository {

    private final DSLContext dslContext;
    private final OrderViewFilterConditionBuilder conditionBuilder;

    @Override
    public PaginationView<OrderSummary> findSummaries(OrderFilter filter, int page, int size) {
        Condition where = conditionBuilder.build(filter);

        List<OrderSummary> items = dslContext
            .select(
                orders.ID,
                orders.STATUS,
                orders.TOTAL_AMOUNT,
                customers.NAME.as("customer_name"),
                count(tickets.ID).as("items_count"),                // ← агрегат прямо в SQL
                orders.CREATED_AT
            )
            .from(orders)
            .leftJoin(customers).on(customers.ID.eq(orders.CUSTOMER_ID))
            .leftJoin(tickets).on(tickets.ORDER_ID.eq(orders.ID))
            .where(where)
            .groupBy(orders.ID, customers.NAME)
            .orderBy(toSortFields(filter.sort()))
            .limit(size).offset((long) page * size)
            .fetch(record -> new OrderSummary(
                record.get(orders.ID),
                record.get(orders.STATUS),
                record.get(orders.TOTAL_AMOUNT),
                record.get("customer_name", String.class),
                record.get("items_count", int.class),
                record.get(orders.CREATED_AT)
            ));

        long total = dslContext.fetchCount(orders, where);
        return new PaginationView<>(items, total, items.size(), page, size);
    }
}

Что тут происходит:

  • Только нужные поляid, status, total_amount, created_at + два derived (имя клиента из join, count тикетов из агрегата).
  • JOIN, не multiset — нам не нужны сами тикеты, нужен только их count. JOIN с count(...) дешевле, чем multiset.
  • Прямая проекция в DTO — без mapper.toDomain (это persistence-маппинг агрегата). Read-DTO собирается прямо в fetch(record -> new OrderSummary(...)).

Что не делаем:

  • Не собираем Order со всеми вложенными частями, чтобы потом «отбросить лишнее».
  • Не используем мультисет — он не для read-проекций.
  • Не идём через OrderRepository.findAll().stream().map(toSummary).toList() — это весь агрегат, потом конверсия, тяжёлый round-trip.

Не CQRS, просто два интерфейса

Иногда <X>ViewRepository путают с query-side в CQRS. Это разные вещи:

  • Здесь — два интерфейса вокруг одной таблицы. Одна транзакция, одно чтение из той же orders. Просто два контракта для удобства.
  • В CQRS — write-side (orders) и read-side (orders_summary_read_model, отдельная таблица, синхронизируется через outbox + Kafka). См. CQRS Style Guide.

CQRS — это инструмент для другой проблемы: read-нагрузка не масштабируется на write-таблице, или read-проекция настолько отличается, что write-схема перестаёт быть удобной для чтения. В UCP это Уровень 3 и решается через скилл ucp-cqrs-design. View-репозиторий — гораздо легче, применяется уже на Уровне 2.

Запрет — перегружать основной интерфейс

R-JOOQ-VIEW-X1: не пихаем read-методы в <X>Repository.

// ПЛОХО
public interface OrderRepository {
    Optional<Order> findById(Long id, SelectMode mode);
    PaginationView<Order> findAll(OrderFilter filter, int page, int size, SelectMode mode);
    void save(Order order);

    PaginationView<OrderSummary> findSummaries(OrderFilter filter, int page, int size);  // ← НЕТ
    List<OrderRow> findExport(OrderFilter filter);                                         // ← НЕТ
    OrderDashboardStats fetchDashboardStats(OffsetDateTime from, OffsetDateTime to);       // ← НЕТ
}

Что не так:

  • Раздувание контракта. На реальном агрегате OrderRepository будет 30+ методов, половина — read-проекции. Читать тяжело, искать нужное долго.
  • Смешение write/read. Read-проекция требует другой структуры запросов, других DTO, других тестов. В одном интерфейсе это смешивается и расходится.
  • DI-граф запутывается. Один handler инжектит OrderRepository ради write, и через тот же бин может случайно вызвать read-метод, лишний load на БД и на сам handler.

Два интерфейса — два контракта, две зоны ответственности. Чисто.

Куда дальше

  • jOOQ Style Guide → раздел 11. View-репозитории — нормативные формулировки.
  • CQRS Style Guide — когда read-проекции эволюционируют в отдельную read-side с собственной таблицей.
  • Repository pattern в jOOQ — write-aggregate репозиторий, который мы НЕ перегружаем view-методами.
  • Пагинация — PaginationView<OrderSummary> возвращается из view-репозиториев тоже.