Опирается на правила:
R-JOOQ-MAP-1…R-JOOQ-MAP-7иR-JOOQ-MAP-X1…R-JOOQ-MAP-X3из jOOQ Style Guide → раздел 7. Маппинг record ↔ domain.
Важно знать
- Маппер — plain Java class (
@Component), не MapStruct interface. Причина: assemble-логика, multiset-разворот, enum-конверсия — MapStruct неудобно покрывает.- Двусторонний маппинг:
toDomain(pojo)иfromDomain(entity). Для сборки агрегата из плоских POJO — отдельныйassembleAggregate(parent, children...).- Enum-перевод — через generated jOOQ-enum +
<enumConverter>true</enumConverter>в forced types. Тогда маппер не делает ручной конверсии.- JSONB — через
JooqJsonbHelper(Spring-bean с injectedObjectMapper).- Timestamp-конверсия не делается в маппере вручную —
forcedType OffsetDateTimeв codegen-time это уже сделал.- Child-маппер инжектится в parent-маппер. Делегация, не наследование, не статика.
- Маппер живёт рядом с репозиторием в
persistence/.../<entity>/. Это persistence-деталь, не domain.
Маппер — это обратная сторона Repository pattern. Репозиторий возвращает домен (Order), но физически берёт POJO (OrdersPojo) — между ними нужна конверсия. Маппер живёт в persistence-слое, делает её осмысленно (включая разворот multiset-результатов и сборку агрегата из плоских частей), и не утекает в domain. Раскрытие правил R-JOOQ-MAP-* ниже.
Plain Java вместо MapStruct
R-JOOQ-MAP-1: маппер — @Component, а не MapStruct-интерфейс.
MapStruct хорошо работает, когда у вас два POJO с одинаковыми полями и нужен механический копир. Но record ↔ domain — это не механический копир:
- Assemble-логика.
Orderсобирается изOrdersPojo(header) +List<TicketsPojo>(children, развернутые из multiset). MapStruct умеет multi-source mapping, но синтаксис тяжёлый, и работа сList<>-параметром малочитаема. - Enum-конверсия с особенностями. Перевод
OrderStatus(domain) ↔OrdersStatus(jOOQ-generated) часто требует валидации «такого значения нет в enum». В MapStruct это@AfterMappingили custom converter, что съедает выгоду. - JSONB-сериализация. Распаковка
JSONB→Addressvalue object идёт черезObjectMapper. MapStruct не делает JSON. - Domain-валидация. Перед созданием
Orderиногда хочется проверить инвариант — это плохо ложится на MapStruct-парадигму «всё статично».
Plain Java + @RequiredArgsConstructor решает всё это явно:
@Component
@RequiredArgsConstructor
public class OrderDomainRecordMapper {
private final TicketDomainRecordMapper ticketMapper; // child-маппер
private final JooqJsonbHelper jsonb; // JSONB → value object
public Order toDomain(OrdersPojo pojo) {
return new Order(
new OrderId(pojo.getId()),
new CustomerId(pojo.getCustomerId()),
pojo.getStatus(), // enum уже OrderStatus благодаря enumConverter
jsonb.deserialize(pojo.getShippingAddress(), Address.class),
pojo.getTotalAmount(),
pojo.getCreatedAt()
);
}
public OrdersPojo fromDomain(Order entity) {
return new OrdersPojo()
.setId(entity.id().value())
.setCustomerId(entity.customer().id().value())
.setStatus(entity.status())
.setShippingAddress(jsonb.serialize(entity.shippingAddress()))
.setTotalAmount(entity.totalAmount())
.setCreatedAt(entity.createdAt());
}
public Order assembleAggregate(OrdersPojo header, List<TicketsPojo> ticketPojos) {
List<Ticket> tickets = ticketPojos.stream()
.map(ticketMapper::toDomain)
.toList();
return toDomain(header).withTickets(tickets);
}
}
R-JOOQ-MAP-X3 запрещает MapStruct, если у маппинга есть assemble или enum-конверсия. Если же у вас два POJO с одинаковыми полями (что в этом слое — редкость), MapStruct ок.
Двусторонний маппинг
R-JOOQ-MAP-2: toDomain и fromDomain — оба должны быть. Плюс assembleAggregate(parent, children...) для сборки из multiset-результата.
Почему явно две стороны:
- Read-flow (query handler): jOOQ → POJO →
toDomain→ handler возвращаетOrderв DTO. - Write-flow (command handler): handler меняет
Order→fromDomain→ POJO → jOOQ-INSERT/UPDATE.
Эти две операции не симметричны. fromDomain иногда теряет данные (computed-поля, которые БД сама заполнит), toDomain иногда вычисляет (агрегаты, флаги). Делать «универсальный конвертер» через рефлексию — путь к багам.
Assemble — отдельный метод
R-JOOQ-MAP-2 (продолжение): когда агрегат собирается из multiset, имеем плоские части. Сборка — отдельный метод:
public Order assembleAggregate(OrdersPojo header,
List<TicketsPojo> ticketPojos,
Map<Long, List<InsurancesPojo>> insurancesByTicket) {
List<Ticket> tickets = ticketPojos.stream()
.map(t -> ticketMapper.assembleAggregate(t,
insurancesByTicket.getOrDefault(t.getId(), List.of())))
.toList();
return toDomain(header).withTickets(tickets);
}
Этот метод вызывается из репозитория после fetch:
return dslContext
.select(orders.asterisk(), buildTicketsMultiset())
.from(orders)
.where(orders.ID.eq(orderId))
.fetchOptional(record -> {
OrdersPojo header = record.into(orders).into(OrdersPojo.class);
List<TicketsPojo> tickets = RecordMappingUtils.getPojoList(record, TICKETS, TicketsPojo.class);
return mapper.assembleAggregate(header, tickets);
});
Enum через enumConverter
R-JOOQ-MAP-3: вариант 1 (предпочтительный) — forcedType в codegen с <enumConverter>true</enumConverter>:
<forcedType>
<userType>ru.example.OrderStatus</userType>
<enumConverter>true</enumConverter>
<includeExpression>.*\.STATUS</includeExpression>
</forcedType>
Тогда pojo.getStatus() уже возвращает OrderStatus (domain). Маппер не делает ничего особенного — просто entity.setStatus(pojo.getStatus()).
Вариант 2 (legacy) — конверсия вручную:
// jooq → domain
OrderStatus status = pojo.getStatus() != null
? OrderStatus.fromValue(pojo.getStatus().getLiteral())
: null;
// domain → jooq
pojo.setStatus(ru.example.jooq.enums.OrderStatus.lookupLiteral(domainStatus.getValue()));
Этот вариант остался в сервисах, которые не использовали enumConverter в начале. При новой разработке — всегда enumConverter.
См. R-JOOQ-CFG-6 в Codegen.
JSONB через JooqJsonbHelper
R-JOOQ-MAP-4: JSONB-колонки — через bean с injected ObjectMapper.
@Component
@RequiredArgsConstructor
public class JooqJsonbHelper {
private final ObjectMapper objectMapper;
public <T> T deserialize(JSONB jsonb, Class<T> clazz) {
if (jsonb == null) return null;
try {
return objectMapper.readValue(jsonb.data(), clazz);
} catch (JacksonException e) {
throw new JsonbDeserializationException("Failed to deserialize JSONB to " + clazz, e);
}
}
public JSONB serialize(Object value) {
if (value == null) return null;
try {
return JSONB.valueOf(objectMapper.writeValueAsString(value));
} catch (JacksonException e) {
throw new JsonbSerializationException("Failed to serialize " + value.getClass(), e);
}
}
}
Использование в маппере:
jsonb.deserialize(pojo.getShippingAddress(), Address.class);
jsonb.serialize(entity.shippingAddress());
Почему отдельный bean, а не использование ObjectMapper напрямую:
- Единая точка для JSONB-конверсии. Если завтра нужно добавить custom deserializer (
Moneyс валютой, например), правка в одном месте. - Осмысленные ошибки.
JsonbDeserializationExceptionнесёт контекст — Jackson из коробки бросает менее информативные. - Spring-управляемая зависимость.
ObjectMapperинжектится туда, а не создаётсяnewкаждый раз.
Timestamp — не конвертим вручную
R-JOOQ-MAP-5: timestamp-колонка → OffsetDateTime уже сделана через forcedType в codegen (R-JOOQ-CFG-4). Маппер просто пробрасывает значение, никаких Instant.from(...), LocalDateTime.atOffset(...) руками.
Если в маппере встречается timestamp-конверсия — это red flag: либо forcedType не настроен, либо в схеме timestamp без zone (нарушение PG Style Guide).
Делегация child-маппера
R-JOOQ-MAP-6: child-маппер — Spring-зависимость, не статика, не наследование.
@Component
@RequiredArgsConstructor
public class OrderDomainRecordMapper {
private final TicketDomainRecordMapper ticketMapper; // ← инжектируется
// ...
}
@Component
@RequiredArgsConstructor
public class TicketDomainRecordMapper {
private final InsuranceDomainRecordMapper insuranceMapper; // ← цепочка
}
Почему делегация, а не один большой маппер «на агрегат»:
- Переиспользование.
TicketDomainRecordMapperнужен и вOrderDomainRecordMapper, и вTicketViewRepository(отдельный read-репозиторий для view, см. View-репозитории). - Test-isolation. Можно протестировать
TicketDomainRecordMapperотдельно от order-сценария. - DI-граф. Spring видит, кто от кого зависит. Это даёт явную картину «эта мапперная пирамида существует».
Наследование (abstract DomainMapper<P, D>) тоже могло бы работать, но добавляет вертикаль типов без выгоды — каждый маппер всё равно уникален.
Расположение — рядом с репозиторием
R-JOOQ-MAP-7: маппер в persistence/.../<entity>/, не в core/.
persistence/
└── order/
├── JooqOrderRepository.java
├── OrderDomainRecordMapper.java ← маппер
├── OrderFilterConditionBuilder.java ← filter builder
└── OrdersSortField.java ← jOOQ-специфичный sort enum
core/
└── domain/
└── order/
├── Order.java ← entity
├── OrderStatus.java
├── repository/
│ ├── OrderRepository.java ← interface
│ └── filter/OrderFilter.java
Маппер — это persistence-деталь. Он знает про jOOQ POJO, про JSONB, про generated record-types. В domain-слое такого знания быть не должно.
Что запрещено
| Антипаттерн | Правило | Что вместо |
|---|---|---|
| POJO/Record возвращается из public-метода репозитория | R-JOOQ-MAP-X1 | Mapper → domain, наружу только Order |
| Создание domain-entity напрямую из generated record (без маппера) | R-JOOQ-MAP-X2 | Через mapper.toDomain(pojo) |
| MapStruct-interface при наличии assemble или enum-конверсии | R-JOOQ-MAP-X3 | Plain Java @Component |
Куда дальше
- jOOQ Style Guide → раздел 7. Маппинг record ↔ domain — нормативные формулировки.
- Multiset — откуда берутся плоские части для
assembleAggregate. - Codegen — почему
OffsetDateTimeв codegen-time, не в маппере. - Repository pattern в jOOQ — где маппер инжектируется.