JPA не предоставляет first-class модель для частичных вложенных графов как концепта. Для этого нужны JDBC (ручная сборка), jOOQ (MULTISET) или Blaze Persistence (Entity Views).

Большинство обсуждений вокруг persistence начинается не с той проблемы. Мы сравниваем фреймворки, SQL-инструменты, ORM… Но реальная проблема проще и фундаментальнее:

Реляционный JOIN результат имеет плоскую форму по умолчанию. Приложениям нужны вложенные объектные графы или специализированные формы данных.

Реляционная реальность

Рассмотрим простую модель: Owner → Pet → Visit

В реляционной базе — три таблицы с foreign key связями. После JOIN:

Дублирование строк, картезианский взрыв, несоответствие структуры. Это не баг фреймворка. Это фундаментальное несоответствие между реляционной и объектной моделями.

EntityGraph: управление загрузкой, не формой данных

@EntityGraph(attributePaths = { "pets", "pets.visits" })
Owner findById(String id);

Один запрос, нет N+1, загружается полный граф. Но здесь важно понять ограничение:

  • EntityGraph управляет загрузкой, identity graph - какие связи подгрузить и когда.

  • Projection управляет shape of result, то есть какую форму должны принять данные.

Это разные задачи. EntityGraph отвечает на вопрос «как загрузить полный граф?». Но не на вопрос «что если нужна другая форма?»

Например:

record OwnerListView(
    String id,
    String name,
    List<String> pets  // только имена, не сущности
)

Здесь не нужны Visit-сущности и полная модель Pet. EntityGraph здесь заканчивается.

Реальная проблема: кто строит граф?

Кто отвечает за построение вложенного объектного графа?

Это ось архитектурного выбора:

  • JDBC → граф строит приложение

  • jOOQ → граф строит SQL-абстракция

  • Blaze → граф строит ORM

Разные инструменты решают одну и ту же проблему на разных архитектурных уровнях.

Подход 1. JDBC: граф строит приложение

SQL возвращает плоские строки. Граф собирается вручную за один проход - паттерн accumulator:

static List<OwnerProjection> extractWithGraph(ResultSet rs) throws SQLException {
    Map<String, OwnerProjection> owners = new LinkedHashMap<>();

    while (rs.next()) {
        String ownerId = rs.getString("owner_id");

        OwnerProjection owner = owners.computeIfAbsent(
            ownerId, id -> OwnerProjection.of(id, rs.getString("owner_name"))
        );

        String petId = rs.getString("pet_id");
        if (petId != null) {
            PetProjection pet = owner.getOrCreatePet(petId, rs.getString("pet_name"));

            String visitId = rs.getString("visit_id");
            if (visitId != null) {
                pet.getOrCreateVisit(visitId, rs.getDate("visit_date").toLocalDate());
            }
        }
    }
    return List.copyOf(owners.values());
}

getOrCreatePet и getOrCreateVisit - computeIfAbsent внутри каждого аккумулятора:

class OwnerProjection {
    private final Map<String, PetProjection> pets = new LinkedHashMap<>();

    PetProjection getOrCreatePet(String id, String name) {
        return pets.computeIfAbsent(id, k -> PetProjection.of(id, name, this.id));
    }
}

class PetProjection {
    private final Map<String, VisitProjection> visits = new LinkedHashMap<>();

    VisitProjection getOrCreateVisit(String id, LocalDate date) {
        return visits.computeIfAbsent(id, k -> VisitProjection.of(id, date, this.id));
    }
}

OwnerProjection, PetProjection, VisitProjection - мутабельные аккумуляторы, скрытые за package-private. Наружу выходят только иммутабельные record-типы через ViewMapper.

Результат одного прохода:

  • полный контроль над SQL

  • один запрос, нет N+1

  • нет внешних зависимостей — промежуточные классы на каждый уровень — ручная дедупликация через LinkedHashMap

Рабочий пример:

github.com/java-backend-architecture/persistence-graph-extraction-jdbc

Подход 2. jOOQ MULTISET: граф строит SQL-абстракция

jOOQ переносит сборку графа в SQL-слой. Запрос сразу возвращает вложенную структуру:

dsl.select(
    OWNERS.ID,
    OWNERS.NAME,
    multiset(
        select(
            PETS.ID, PETS.NAME, PETS.OWNER_ID,
            multiset(
                select(VISITS.ID, VISITS.DATE, VISITS.PET_ID)
                    .from(VISITS)
                    .where(VISITS.PET_ID.eq(PETS.ID))
            ).convertFrom(r -> r.map(Records.mapping(VisitView::new)))
        )
        .from(PETS)
        .where(PETS.OWNER_ID.eq(OWNERS.ID))
    ).convertFrom(r -> r.map(Records.mapping(PetView::new)))
)
.from(OWNERS)
.fetch(Records.mapping(OwnerView::new));

Никаких промежуточных проекций, никакой ручной дедупликации. Records.mapping собирает record-типы напрямую.

Для плоского списка ещё лаконичнее:

dsl.select(
    OWNERS.ID,
    OWNERS.NAME,
    multiset(
        select(PETS.NAME)
            .from(PETS)
            .where(PETS.OWNER_ID.eq(OWNERS.ID))
    ).convertFrom(r -> r.map(rec -> rec.get(PETS.NAME)))
)
.from(OWNERS)
.fetch(Records.mapping(OwnerListView::new));
  • нет ручной сборки графа

  • типобезопасность на уровне таблиц и колонок

  • минимум вспомогательного кода, требует кодогенерации, порядок полей в SELECT должен совпадать с порядком параметров конструктора, проверяется только в рантайме, MULTISET в production требует PostgreSQL или другой СУБД с полной поддержкой, H2 используется только в тестах для упрощения локального запуска

Рабочий пример:

github.com/java-backend-architecture/persistence-graph-extraction-jooq

Подход 3. JPA + Blaze Persistence: граф строит ORM

Blaze вводит декларативную модель через Entity Views:

@EntityView(OwnerEntity.class)
interface OwnerEntityView {
    @IdMapping String getId();
    String getName();
    List<? extends PetEntityView> getPets();
}

@EntityView(PetEntity.class)
interface PetEntityView {
    @IdMapping String getId();
    String getName();
    @Mapping("owner.id") String getOwnerId();
    List<? extends VisitEntityView> getVisits();
}

Форма данных описывается декларативно. Blaze генерирует оптимизированные запросы автоматически. Но это не магия без настройки. Каждая вьюха регистрируется явно:

@Bean
EntityViewManager entityViewManager(CriteriaBuilderFactory cbf) {
    EntityViewConfiguration config = EntityViews.createDefaultConfiguration();
    config.addEntityView(OwnerEntityView.class);
    config.addEntityView(PetEntityView.class);
    config.addEntityView(VisitEntityView.class);
    config.addEntityView(OwnerListEntityView.class);
    return config.createEntityViewManager(cbf);
}

Маппинг в application read-модель по прежнему выполняет ViewMapper:

var setting = EntityViewSetting.create(OwnerEntityView.class);
var cb = cbf.create(em, OwnerEntity.class).where("id").eq(id);

return evm.applySetting(setting, cb)
    .getResultList()
    .stream()
    .findFirst()
    .map(ViewMapper::toView);

По умолчанию Blaze генерирует один оптимизированный запрос. При нескольких независимых коллекциях в одном EntityView может выполнить отдельный запрос для каждой. Для полной типобезопасности предикатов: where(OwnerEntity_.id).eq(id)

  • декларативные проекции

  • оптимизированные запросы генерируются автоматически

  • нет ручной дедупликации, генерируемый SQL скрыт, сложнее предсказать форму запроса и reasoning о производительности, при сложных графах возможен query explosion, нужна явная верификация, на production требует BlazeConfig и регистрации каждой вьюхи, дополнительная зависимость

Рабочий пример:

github.com/java‑backend‑architecture/persistence‑graph‑extraction‑jpa

Сравнение

Когда что выбирать

  • JDBC - полный контроль над SQL, нет желания тащить зависимости, нестандартные формы данных. Цена - промежуточные классы и ручная дедупликация.

  • jOOQ - типобезопасность на уровне SQL и готовность к кодогенерации. Лучший баланс между контролем и лаконичностью для сложных проекций.

  • JPA + Blaze - уже используете JPA, нужны декларативные проекции с минимумом ручного маппинга. Принимайте в расчёт: SQL скрыт, поведение на production нужно верифицировать отдельно.

Ключевая мысль

Persistence это не про то, как загрузить данные.

Это про то, кто и где формирует их структуру.

  • JDBC → приложение контролирует каждый шаг

  • jOOQ → SQL-слой берёт сборку на себя

  • Blaze → ORM декларирует форму и генерирует запрос

Выбор инструмента — это выбор того, где живёт ответственность за сборку графа. И это решение влияет на архитектуру всего persistence-слоя.

Загружать данные легко. Правильно формировать их структуру это настоящая инженерная задача.

Комментарии (14)


  1. urvanov
    07.06.2026 11:10

    Один запрос, нет N+1, загружается полный граф.

    Если дочерние сущности замаплены как Set, а не как List, то запрос вполне может быть только один, на самом деле. Только за hashCode и equals надо будет ещё очень аккуратно следить.

    А так да, определённая проблема, действительно, существует.


    1. DmitriiRussuLink Автор
      07.06.2026 11:10

      Полностью с Вами согласен.

      На практике многие команды вообще не усложняют решение подобных задач и ограничиваются маппингом только необходимых полей из persistence-модели во view-модель.

      Для большого числа сценариев этого вполне достаточно.


  1. boevlab
    07.06.2026 11:10

    Очень полезная статья


  1. aleksandy
    07.06.2026 11:10

    jdbc, jooq, blaze… А зачем, зачем тянуть дополнительные зависимости (ну, кроме jdbc)?

    Почему бы Criteria API не использовать? Код бы почти не отличался от jdbc, но оставался бы в рамках JPA.

    А если упороться, то можно написать стримовских коллекторов, получать из запроса стрим результатов, а его уже собирать.


    1. DmitriiRussuLink Автор
      07.06.2026 11:10

      1. По поводу Criteria API.

        Статья Thorben Janssen - "Создавайте более эффективные запросы Criteria с помощью механизма сохранения данных Blaze".

        Цитата - "К сожалению, Criteria API от JPA не очень популярен, поскольку его сложно читать и писать".

        Ссылка - https://thorben-janssen.com/create-better-criteria-queries-with-blaze-persistence/

        Цитата из поста на линкдине Thorben Janssen - "There’s one thing I don’t like in Hibernate / JPA… and that’s the Criteria API!".

        Можно найти через поиск гугл.

      Статья Влад Михалча - Blaze Persistence – "Лучший способ написания запросов JPA Criteria Queries".

      Ссылка - https://vladmihalcea.com/blaze-persistence-jpa-criteria-queries/

      2. По поводу стримовских коллекторов.

      Это тот же JDBC-подход из статьи, просто в функциональном стиле — архитектурно ничего не меняется. Реального стримингового чтения всё равно не будет, драйвер буферизует весь ResultSet. А вложенные коллекторы для трёх уровней читаются хуже, чем явный цикл! Те же яйца только в профиль.

      3. По поводу зависимостей.

      А вы в папочку с зависимостями когда-то заглядывали? Видели сколько их? Там уже Spring, Hibernate, Jackson и ещё полсотни транзитивных. На фоне этого jOOQ или Blaze — просто капля в море. Аргумент “не хочу лишних зависимостей” работает, только если вы пишете на чистом Java SE без ничего.

      Если есть аргументация Ваших допущений, то буду раз ознакомиться!

      0


      1. aleksandy
        07.06.2026 11:10

        1. Вкусовщина и аппеляция к авторитету, не убедительно.

        2. Я так и сказал, просто код будет более однородным, т.к. написан в одном стиле.

        Реального стримингового чтения всё равно не будет, драйвер буферизует весь ResultSet

        Просто готовить правильно надо. Конечно, зависит от драйвера, но оракловый и постгресовый точно так не делают. Проверено неоднократно.

        1. Лучший код - тот, которого нет. Если можно обойтись без чего-либо, то нужно обходиться без этого. Зависимость - это не только лишнее место на диске, это ещё и баги, техдолг с обновлением и совместимостью.


        1. DmitriiRussuLink Автор
          07.06.2026 11:10

          По пункту 1. Окей, авторитеты не аргумент. Тогда конкретно: что именно вам нравится в Criteria API? Многословность? Необходимость писать cb.equal(root.get(“name”), “value”) вместо читаемого условия? Если есть практический кейс, где Criteria API выигрывает у альтернатив, покажите, обсудим.

          По пункту 2. “Однородность стиля” это аргумент про эстетику, не про инженерию. Если задача собрать вложенный граф из плоского ResultSet, то вопрос не в стиле, а в читаемости и сопровождаемости конкретного кода. Вложенные коллекторы для трёх уровней вложенности, покажите рабочий пример, который читается лучше явного цикла. Буду рад ошибиться.

          По поводу буферизации, драйвер PostgreSQL по умолчанию буферизует весь ResultSet, если не выставить fetchSize и не открыть транзакцию явно. Это не моё утверждение, это документация. Если у вас другой опыт, какой именно fetchSize, какая транзакция, какой объём данных?

          По пункту 3. “Лучший код тот, которого нет” это принцип YAGNI, и он хорош против оверинжиниринга. Но здесь он применён наоборот: вы предлагаете писать руками то, что инструмент делает надёжнее и декларативнее. Получается больше кода, но своего. Это не минимализм, это NIH-синдром.

          Зависимость да, это техдолг. Но самописный accumulator-маппер для трёх уровней вложенности тоже техдолг, только без community, без документации и без багфиксов от чужих команд.


          1. aleksandy
            07.06.2026 11:10

            что именно вам нравится в Criteria API? Многословность?

            Во-первых, я нигде не говорил, что мне он нравится? Да, он многословен, но он “уже есть”, иметь инструмент и не использовать его - глупо. Во-вторых, Criteria API - это крайний случай, когда не получается выразить запрос через самописный аналог спринговых спецификаций работающих с наследниками jakarta.persistence.metamodel.Attribute. Т.е. в коде у меня будет repo.find(SpecificationBuilder.eq(Entity_.name, “Name”)) и т.п. Внутри это, естественно, всё так или иначе разворачивается в Criterai API. Но это сделано давно, протестировано и не вызывает ни у кого никаких вопросов.

            не выставить fetchSize и не открыть транзакцию явно

            А если запрос неправильно написать, то можно вообще не те данные получить. В чём проблема этот fetchSize выставить? А транзакция так или иначе тем же спрингом будет открыта/закрыта.

            какой именно fetchSize

            Зависит от ситуации, может быть и 50, и 100, и 1000.

            какая транзакция

            Не понял вопроса: есть и readOnly, и полноценные с потоковыым изменением сущностей.

            какой объём данных?

            Разный, и десятки, и сотни тысяч и миллионы. Тут главное либо чистить кэш первого уровня, либо не тягать сущности полностью, если нужны пара полей.

            предлагаете писать руками

            Представьте себе, это часть моей работы.

            инструмент делает надёжнее и декларативнее

            Чтобы сделать select e from Entity e where e.attribute1 = ?1 and e.attribute2 > ?2 мне декларативность не нужна. А написать пару-тройку запросов руками - это куда надёжнее, чем новую тащить зависимость, у которой могут быть транзитивные завиcимости со своими багами и которой ещё надо научиться правильно пользоваться, т.к. в ней тоже могут быть нюансы.

            Получается больше кода, но своего

            Кода получается не больше, а ровно столько, сколько нужно. И без лишней шелухи.

            Вложенные коллекторы для трёх уровней вложенности, покажите рабочий пример, который читается лучше явного цикла.

            Опять же не надо придумывать то, чего я не говорил. Я разве сказал где-то о вложенных коллекторах? Я говорил о коллекторах в общем, для каждого случая - свой. И читаться он будет ничуть не хуже, чем цикл. Т.к. по-сути, всё, что находится за телом цикла станет полями коллектора, а тело цикла - телом аккумулятора.


            1. DmitriiRussuLink Автор
              07.06.2026 11:10

              Вы знаете. Мне кажется, что мы спорим о наработанной привычке. Как стать профессионалом? Повторить что-то 10 000 раз. После этого технология становиться - ясной и доступной. И когда другие говорят, что им тяжеловато понимать / разбираться у нас недоумение? Как непонятно? Все просто как кирпич! И вообще программирование - неоднозначная тема. И в изложении и в применении. Так что Ваша точка зрения тоже имеет право на существование. Может и я когда-нибудь приспособлюсь к Criteria API и буду как и Вы отстаивать ее простоту и полезность. Но сейчас лично мне легче воспринимать jdbc. Статья как бы не для Вас. Вы ее переросли. И набрали с опытом предубеждений (как и все мы). А новичкам она поможет сделать именно свой осознанный выбор. И ознакомится с различными практиками (тем более рекомендуемыми авторитетами), а не только с Criteria API.


            1. DmitriiRussuLink Автор
              07.06.2026 11:10

              И вообще, если у Вас имеются фундаментальные наработки, то почему бы Вам не поделиться с нами в отдельной статье, логическими рассуждениями, примерами кода.

              Как сделали Thorben Janssen, Влад Михалча и я.

              Кинуть парочку критических комментариев не очень сложно. А провести исследование и сделать выводы не у каждого получается.

              Мы вообще-то инженеры. Нам нужен код, метрики и сравнения, а не голословные утверждения.

              Без четких критериев, все возражения сводятся к системе Станиславского - верю не верю. Не убедил!

              Ждем от Вас разоблачительной статьи по поводу ошибок Thorben Janssen и Влада Михалчи, раз по Вашему мнению они ошибаются.

              И не надо отказываться - Вкусовщина и аппеляция к авторитету, не убедительно.

              Это Ваши слова.

              Помните как у Булгакова? Шариков по поводу переписки Каутского с Энгельсом.

              На вопрос Профессора.

              «— Да не согласен я.

              — С кем?

              — С обоими.

              — Это замечательно, клянусь богом.

              … А что бы вы со своей стороны могли предложить?

              — Да что тут предлагать… Взять всё, да и поделить.»

              В нашем варианте.

              Да не согласен я.

              С кем.

              С обоими (Thorben Janssen и Влад Михалча).

              Это замечательно, клянусь богом.

              А что бы вы со своей стороны могли предложить?

              Да что тут предлагать… Взять да и просто использовать Criteria API. Нечего тут предлагать!»


            1. DmitriiRussuLink Автор
              07.06.2026 11:10

              Criteria API это инструмент для построения динамических условий выборки. Но он решает другую задачу.

              Статья о том, кто и где формирует форму результата! Частичный вложенный граф из плоского JOIN. Criteria API возвращает либо полные сущности, либо плоские Tuple. Граф из этого всё равно собирает приложение — тем же accumulator-паттерном, что и в JDBC-примере.

              Поэтому вопрос "зачем jOOQ/Blaze, если есть Criteria API" - это вопрос из другой плоскости. Не лучше/хуже, а просто про другое!!!!! Вы вообще поняли о чем статья?


  1. DmitriiRussuLink Автор
    07.06.2026 11:10

    1. По поводу Criteria API.

      Статья Thorben Janssen - "Создавайте более эффективные запросы Criteria с помощью механизма сохранения данных Blaze".

      Цитата - "К сожалению, Criteria API от JPA не очень популярен, поскольку его сложно читать и писать".

      Ссылка - https://thorben-janssen.com/create-better-criteria-queries-with-blaze-persistence/

      Цитата из поста на линкдине Thorben Janssen - "There’s one thing I don’t like in Hibernate / JPA… and that’s the Criteria API!".

      Можно найти через поиск гугл.

    Статья Влад Михалча - Blaze Persistence – "Лучший способ написания запросов JPA Criteria Queries".

    Ссылка - https://vladmihalcea.com/blaze-persistence-jpa-criteria-queries/

    2. По поводу стримовских коллекторов.

    Это тот же JDBC-подход из статьи, просто в функциональном стиле — архитектурно ничего не меняется. Реального стримингового чтения всё равно не будет, драйвер буферизует весь ResultSet. А вложенные коллекторы для трёх уровней читаются хуже, чем явный цикл! Те же яйца только в профиль.

    3. По поводу зависимостей.

    А вы в папочку с зависимостями когда-то заглядывали? Видели сколько их? Там уже Spring, Hibernate, Jackson и ещё полсотни транзитивных. На фоне этого jOOQ или Blaze — просто капля в море. Аргумент “не хочу лишних зависимостей” работает, только если вы пишете на чистом Java SE без ничего.

    Если есть аргументация Ваших допущений, то буду раз ознакомиться!


    1. ris58h
      07.06.2026 11:10

      Вы не туда ответили.

      @aleksandy


      1. DmitriiRussuLink Автор
        07.06.2026 11:10

        Спасибо!