Опирается на правила:
R-JOOQ-VIEW-1…R-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-репозиториев тоже.