Опирается на правила: R-JOOQ-MAP-1R-JOOQ-MAP-7 и R-JOOQ-MAP-X1R-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 с injected ObjectMapper).
  • 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-сериализация. Распаковка JSONBAddress value 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 меняет OrderfromDomain → 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-X1Mapper → domain, наружу только Order
Создание domain-entity напрямую из generated record (без маппера)R-JOOQ-MAP-X2Через mapper.toDomain(pojo)
MapStruct-interface при наличии assemble или enum-конверсииR-JOOQ-MAP-X3Plain Java @Component

Куда дальше

  • jOOQ Style Guide → раздел 7. Маппинг record ↔ domain — нормативные формулировки.
  • Multiset — откуда берутся плоские части для assembleAggregate.
  • Codegen — почему OffsetDateTime в codegen-time, не в маппере.
  • Repository pattern в jOOQ — где маппер инжектируется.