Реляционные базы данных по-прежнему остаются главным хранилищем наших данных. А значит, вопрос выбора инструмента отображения данных из БД на уровне приложения - всё так же актуален.

Долгое время я выбирал: Spring Data JPA. Уверен, что большинства из вас — тоже. Но времена меняются, и в 2025 для своих новых проектов я использую — Spring Data JDBC.

Почему? Если вам стало любопытно — добро пожаловать под кат.

ORM важен

Нет, это не очередная статья от хейтера ORM. Я убеждён, что ORM приносит больше пользы, чем вреда. Маппинг объектов на таблицы, типизация, удобство работы с данными - всё это экономит часы разработки. Однако ORM-ы бывают разные.

В экосистеме Spring у нас есть выбор:

  • Spring Data JPA

  • Spring Data JDBC

  • Spring Data R2DBC

Давайте взглянем на ситуацию в начале 2025 года. Впрочем, с тех пор мало что изменилось.

Spring Data R2DBC сразу вынесем за скобки — реактивный стек я использую редко, и подозреваю, что большинство из вас тоже.

Spring Data JDBC — относительно новый игрок. Выбор в его пользу несёт определённый риск: можно столкнуться с неожиданными ограничениями или отсутствием нужной функциональности.

Казалось бы, просто продолжай использовать Spring Data JPA — как говорится, ещё ни одного менеджера не уволили за покупку Oracle. Проверенный временем фреймворк, по которому написаны сотни статей и сделаны десятки докладов. Именно его я и выбирал годами.

Но, честно говоря, я устал...

Что не так с JPA

Состояние сущностей

Начнём с основ. Думаю, многие из вас знают, что сущности в Spring Data JPA могут находиться в нескольких состояниях. Два наиболее важных: managed и detached.

Пока вы делаете всё в рамках одной транзакции — всё хорошо. Hibernate следит за тем, чтобы, каким бы способом вы ни получили сущность (кроме native-запросов, разумеется), это был один и тот же объект с одинаковой ссылкой. Но как только ваши объекты выходят за границы транзакции — вот тут и начинается самое интересное.

А выйти за пределы транзакции рано или поздно придётся. Нужно вызвать внешний сервис? Добро пожаловать по ту сторону. Вам придётся закрыть транзакцию, выполнить запрос, открыть её снова — и встретиться лицом к лицу с «призраками JPA».

Было бы хорошо, если бы вызов внешнего сервиса был единственной проблемой. В конце концов, часто можно организовать код так, чтобы изолировать внешние вызовы. Но с JPA проблемы возникают буквально в тривиальных случаях. Давайте рассмотрим пример:

@Transactional
public Order apply10PercentDiscount(Order order) {
    var discountPercent = BigDecimal.valueOf(0.1);

    List<OrderItem> items = order.getItems();
    items.forEach(i ->
            i.setDiscount(i.getPrice().multiply(discountPercent)));

    return order;
}

Код выглядит достаточно тривиально. Что тут может пойти не так? А пойти не так может буквально всё:

  1. Возможно, всё пройдёт гладко — скидка сохранится в БД

  2. Возможно, скидка будет установлена в объекте, но в БД ничего не попадёт

  3. Нужно вызвать save(order), чтобы изменения сохранились

  4. Нужно вызвать saveAll(items), чтобы изменения сохранились

  5. LazyInitializationException

  6. Так писать вообще нельзя — перепишите немедленно

Что произойдёт при выполнении этого кода? Зависит от контекста: как был вызван метод, как объявлены сущности и связи, в каком состоянии сейчас заказ. Вариативность зашкаливает.

В итоге мы начинаем вводить правила на уровне проекта или команды: как работать с сущностями, как их описывать. Но сколько бы правил вы ни придумали — ошибок не избежать. Вас всё равно ждут долгие часы дебага с мыслью: «Как я тут оказался и почему этот тривиальный случай не работает?»

LazyInitializationException

Если null в Java — это «ошибка на миллиард долларов», то LazyInitializationException — ошибка как минимум на миллион. При разборе любого примера с JPA на вопрос «что здесь произойдёт?» можно смело отвечать: LazyInitializationException — и с высокой вероятностью не ошибёшься.

Возьмём тот же код, что приводили выше. Если вызывающий метод не работает в рамках транзакции, а сущность order не смержена в текущую сессию — в точке getItems() мы получим LazyInitializationException.

Но есть и более тривиальные случаи. Достаточно повесить на сущность аннотации @ToString или @EqualsAndHashCode от Lombok. Или воспользоваться стандартным генератором IntelliJ IDEA для этих методов - и вот вы уже видите в логах приложения LazyInitializationException.

Для примера сравните как выглядит toString и equals, hashcode сгенерированные стандартными способом в IDEA:

@Override
public boolean equals(Object o) {
    if (o == null || getClass() != o.getClass()) return false;
    Order order = (Order) o;
    return Objects.equals(id, order.id) 
      && Objects.equals(customerName, order.customerName) 
      && Objects.equals(orderDate, order.orderDate) 
      && Objects.equals(totalAmount, order.totalAmount) 
      && status == order.status 
      && Objects.equals(items, order.items);
}

@Override
public int hashCode() {
    return Objects.hash(id, 
                        customerName, 
                        orderDate, 
                        totalAmount, 
                        status, 
                        items);
}

@Override
public String toString() {
    return "Order{" +
            "id=" + id +
            ", customerName='" + customerName + '\'' +
            ", orderDate=" + orderDate +
            ", totalAmount=" + totalAmount +
            ", status=" + status +
            ", items=" + items +
            '}';
}

Сгенерированные специализированным тулингом понимающим специфику JPA: Amplicode/JPA Buddy:

 @Override
public final boolean equals(Object o) {
    if (this == o) {
        return true;
    }
    if (o == null) {
        return false;
    }
    Class<?> objectEffectiveClass = o instanceof HibernateProxy proxy 
      ? proxy.getHibernateLazyInitializer().getPersistentClass() 
      : o.getClass();
    Class<?> thisEffectiveClass = this instanceof HibernateProxy proxy 
      ? proxy.getHibernateLazyInitializer().getPersistentClass() 
      : this.getClass();
    if (thisEffectiveClass != objectEffectiveClass) {
        return false;
    }
    Order order = (Order) o;
    return getId() != null && Objects.equals(getId(), order.getId());
}

@Override
public final int hashCode() {
    return this instanceof HibernateProxy proxy 
      ? proxy.getHibernateLazyInitializer().getPersistentClass().hashCode() 
      : getClass().hashCode();
}

@Override
public String toString() {
    return getClass().getSimpleName() + "(" +
            "id = " + id + ", " +
            "customerName = " + customerName + ", " +
            "orderDate = " + orderDate + ", " +
            "totalAmount = " + totalAmount + ", " +
            "status = " + status + ")";
}

Ещё одно популярное место для этой ошибки — маппинг сущностей в DTO. Проблема настолько распространена, что породила в сообществе целые баталии на тему «где правильно делать маппинг Entity ↔ DTO». На эту тему даже делают доклады на конференциях.

Проблемы с производительностью

О проблемах с производительностью в JPA не говорит только ленивый. Умение обходить все острые углы фреймворка стало чуть ли не признаком Senior-разработчика. Просто остановитесь на секунду и задумайтесь об этом.

Если вы продолжили читать — значит, хотите стать тем самым Senior. От части проблем вас может уберечь профессиональный JPA-тулинг, но давайте разберёмся, что же тормозит в вашем Spring Data JPA приложении. А тормозить там, поверьте, есть чему.

Бесконечные проблемы N+1

Предполагаю, вы знакомы с тем, что такое связь между JPA-сущностями и умеете их настраивать. Не будем в это углубляться — лучше рассмотрим самую частую проблему, связанную с ними: N+1.

Классический пример: для каждого заказа нужно получить его позиции. Допустим, мы хотим рассчитать общую сумму по неоплаченным заказам.

@Transactional
public BigDecimal totalAmountAllPendingOrders() {
    List<Order> orders = orderRepository.findByStatus(Order.OrderStatus.PENDING);

    return orders.stream()
            .flatMap(o -> o.getItems().stream())
            .map(i -> i.getPrice().multiply(BigDecimal.valueOf(i.getQuantity())))
            .reduce(BigDecimal.ZERO, BigDecimal::add);
}

Запустите этот код с включённым логированием SQL — и в консоли увидите множество однотипных запросов:

Hibernate: select i1_0.order_id,i1_0.id,i1_0.discount,i1_0.price,i1_0.product_name,i1_0.quantity from order_items i1_0 where i1_0.order_id=?
Hibernate: select i1_0.order_id,i1_0.id,i1_0.discount,i1_0.price,i1_0.product_name,i1_0.quantity from order_items i1_0 where i1_0.order_id=?
Hibernate: select i1_0.order_id,i1_0.id,i1_0.discount,i1_0.price,i1_0.product_name,i1_0.quantity from order_items i1_0 where i1_0.order_id=?

Поздравляю — вы только что столкнулись с проблемой N+1.

Первое, что пробует начинающий JPA-разработчик — поставить FetchType.EAGER над связью в сущности. Но мы-то с вами знаем, что это не поможет.

А вот что поможет - как утверждают эксперты - так это join fetch в JPQL-запросе:

@Query("select o from Order o join fetch o.items where o.status = ?1")
List<Order> findByStatus(Order.OrderStatus status);

Множественные запросы действительно заменились на один с JOIN. Казалось бы, просто пишем JPQL-запросы — и проблема больше не возникает? Если вы так подумали — Spring Data JPA рассмеётся вам в лицо.

Помните, в начале статьи мы разбирали код связанный со скидками? Так вот, приходит к нам заказчик и просит в отчете по неоплаченным заказам учесть примененные скидки. Что может быть проще, правим наш метод:

@Transactional
public BigDecimal totalAmountAllPendingOrders() {
    List<Order> orders = orderRepository.findByStatus(Order.OrderStatus.PENDING);

    var totalAmountAll = BigDecimal.ZERO;
    for (Order order : orders) {
        BigDecimal totalAmount = order.getItems().stream()
                .map(i -> i.getPrice().multiply(BigDecimal.valueOf(i.getQuantity())))
                .reduce(BigDecimal.ZERO, BigDecimal::add);

        BigDecimal totalDiscount = order.getDiscounts().stream()
                .map(d -> d.getDiscount().getPercentage().multiply(totalAmount))
                .reduce(BigDecimal.ZERO, BigDecimal::add);

        totalAmountAll = totalAmountAll.add(totalAmount.subtract(totalDiscount));
    }

    return totalAmountAll;
}

И конечно, правим наш JPQL-запрос — мы же Senior-разработчики и помним про N+1:

@Query("select o from Order o join fetch o.items join fetch o.discounts dref join fetch dref.discount where o.status = ?1")
List<Order> findByStatus(Order.OrderStatus status);

Запускаем код и... (театральная пауза) получаем MultipleBagFetchException. ?

Честно говоря, даже не хочу в этом разбираться. Тем более что очередной JPA-гуру в видео или статье советует вообще не использовать join fetch, а вместо этого ставить аннотацию @BatchSize.

Many-to-Many

Если вы думаете, что N+1 — единственная неочевидная проблема с производительностью, хочу вас расстроить (или обрадовать — мы ведь хотим стать Senior). Это далеко не всё.

Настроим связь many-to-many между Order и Tag — создаём поле и добавляем нужные аннотации:

@ManyToMany
@JoinTable(name = "orders_tags",
        joinColumns = @JoinColumn(name = "order_id"),
        inverseJoinColumns = @JoinColumn(name = "tags_id"))
private List<Tag> tags = new ArrayList<>();

Пишем логику назначения тега на заказ. Проверяем, всё работает, никаких ошибок. А теперь заглянем под капот — что происходит при добавлении или удалении очередного тега?

Hibernate: delete from orders_tags where order_id=?
Hibernate: insert into orders_tags (order_id,tags_id) values (?,?)
Hibernate: insert into orders_tags (order_id,tags_id) values (?,?)
Hibernate: insert into orders_tags (order_id,tags_id) values (?,?)

Оказывается, все элементы связи сначала удаляются, а затем вставляются заново. Как говорится: «Теперь вам с этим жить».

Проблемы с Pageable

Любой Senior-разработчик (да и не только Senior) знает: вызывать методы findAll..., возвращающие коллекции, без пагинации — опасно. Если результатов много, можно сорвать джекпот в виде OutOfMemoryError.

В Spring Data JPA есть решение «из коробки» — Pageable. Не самое производительное (посмотрите доклад Ильи и Федора Сазоновых, если ещё не видели), но, как говорится, «достаточно хорошо работает».

Казалось бы, что тут может пойти не так? Просто добавляем Pageable в качестве аргумента метода репозитория — и всё работает.

Однако при «правильном» использовании Pageable не просто решает проблемы — он их создаёт.

Вспомним наш запрос, еще до появления в нем ссылок на Discount, добавим в него Pageable:

@Query("select o from Order o join fetch o.items where o.status = ?1")
List<Order> findByStatus(Order.OrderStatus status, Pageable pageable);

Как обычно запускаем - все работает. Тут главное не пропустить строчку в логах вашего приложения:

HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory

На всякий случай уточню: Hibernate сообщает, что пагинация будет выполняться в памяти вашего приложения (drop the mic).

Проблема кроется в join fetch. Это ещё ладно, если он у вас уже был и вы добавили пагинацию — ситуация глобально не изменилась. Но что если наоборот: была пагинация, а вы добавили join fetch? Похоже, тот эксперт, рассказывавший про @BatchSize, был настоящим экспертом.

Я устал, я ухожу

Не знаю как вам, а мне все эти проблемы Spring Data JPA порядком надоели. Да, они помогут с Job Security — разработчики, умеющие обходить такие ловушки, высоко ценятся на рынке. Но разве вам не жалко времени и ментальных сил на решение этих проблем?

Мне — жалко. Поэтому последние несколько лет я посматриваю на альтернативы.

Как вы, вероятно, догадались из названия статьи, свой выбор я остановил на Spring Data JDBC. Все новые проекты в 2025 и 2026 году я начинаю именно на нём.

Не буду сейчас детально расписывать, как всё устроено — для этого у нас будет целый эвент: Spring Data JDBC — идеальная Data для вашего приложения. Приходите, обсудим. По итогам планирую написать отдельную статью.

Однако кратко отвечу на вопросы, которые поставил в этой статье:

  1. Сущности в Spring Data JDBC не имеют состояния

  2. Сохранение и обновление происходит всегда предсказуемо

  3. Проблемы ленивой загрузки связей нет по определению

  4. Если производительность какой-то операции не устраивает — её всегда можно оптимизировать средствами Spring JDBC

  5. В любой момент можно перейти от Spring Data JDBC к чистому Spring JDBC

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


  1. excentro
    04.12.2025 08:11

    Спасибо за статью. Мне интересно, а spring data jdbc хоть на сколько-то производительнее jpa?


    1. ikuchmin Автор
      04.12.2025 08:11

      К производительности иногда возникают вопросы, но она определённо более предсказуема


  1. Neuronix
    04.12.2025 08:11

    Не встречал ни одного высоконагруженного проекта, где бы использовался JPA. Все на определенном этапе слезают с него на что то более предсказуемое/низкоуровневое, иногда на свои велосипеды, но все они низкоуровневые


  1. AlexViolin
    04.12.2025 08:11

    Проблема настолько распространена, что породила в сообществе целые баталии на тему «где правильно делать маппинг Entity ↔ DTO».

    Если использовать многослойную архитектуру, то ответ на этот вопрос очевиден. DTO находится в более высоко лежащем слое приложения, чем слой в котором находится Entity. Соответственно слой с Entity не знает, что существуют объекты DTO - слои связаны между собой однонаправленно. Поэтому маппинг Entity ↔ DTO происходит в слое в котором находятся объекты DTO.


    1. GreyN
      04.12.2025 08:11

      и в каком слое происходит управление транзакцией?
      в слое, где находятся DTO? тогда это нарушение принципов слоеной архитектуры
      в слое, где идет работа с Entity? тогда добро пожаловать в LazyInitializationExeption, либо слой работы с Entity должен как-то знать, что именно надо вытащить из БД, чтобы на слоях выше LazyInitializationExeption не возникало. а значит у нас знание о структуре DTO неявно перетекает на уровень работы с Entity.

      простите, но все эти "слоеные архитектуры" - очередной marketing bullshit для продажи книг и курсов. ну и еще отличный баттлфилд для холиваров


      1. AlexViolin
        04.12.2025 08:11

        Отличный комментарий - смешаны транзакции, Entity, LazyInitializationException и "слоеные архитектуры" как очередной marketing bullshit.

        Но мне ответить очень легко.

        1. DTO - это не обязательно какой-то аналог Entity. DTO может включать в себя информацию из разных Entity объектов и слой, содержащий DTO, конечно не имеет никакого отношения к тому как и где реализованы транзакции.

        2. Что касается LazyInitializationException, то совершенно не обязательно, что Entity наполняется при помощи ORM фреймворка, который вызывает упомянутую исключительную ситуацию. Но даже при использовании ORM функционал слоя работающего с Entity должен быть реализован так, чтобы исключить возникновение LazyInitializationException.

        3. Что касается транзакций, то типовой алгоритм следующий. Допустим работа с Entity идёт в logic layer. Функционал работающий с Entity помещается внутри функционала use case или по Фаулеру это application logic (это тоже logic layer). При старте метода в объекте application logic запускается транзакция, а затем отрабатывает нужный функционал с Entity. Если при работе метода не возникли exception, то по завершению функционала метода application logic отрабатывает commit транзакции, в противном случае rollback. Возникновение LazyInitializationException откатит транзакцию, а сообщение об этой ошибке покажет разработчику её источник.


        1. GreyN
          04.12.2025 08:11

          при чем тут commit/rollback?!
          мы пока только про выборки. этого достаточно.
          так где же идет управление транзакцией и каким это образом "функционал слоя работающего с Entity должен быть реализован так, чтобы исключить возникновение LazyInitializationException"?
          чтобы исключить LazyInitializationException слой работы с Entity должен как-то знать, что именно потом будет запрошено из этой Entity.
          то есть, слой работы с Entity должен неявно знать структуру будущего DTO.
          и вот у нас уже потекли слои друг в друга.
          причем, неявно.

          Если у меня есть сущность Product и внутри нее есть множество Tag (замапленных как OneToMany), а у Tag есть color
          то вот я выбрал Product по ID и передал его в верхний слой.
          а там у моего Product спросили product.getTags() и побежали по этому списку спрашивая getColor() у каждого элемента.
          что же мы получим в слое работы с DTO, если заранее не вытащить join fetch Tag
          ?

          все еще bullshit


          1. AlexViolin
            04.12.2025 08:11

            Если речь идёт о работе с транзакциями, то конечно надо знать условия вызова commit/rollback.

            слой работы с Entity должен неявно знать структуру будущего DTO

            Слой работающий с DTO более высоко лежащий, чем слой с Entity. И как раз слой с DTO знает всё про наличие и структуру Entity. И это вполне естественно, так как DTO наполняется данными из объектов Entity. А слой с Entity ничего не знает про слой с DTO. Такова логика многослойной архитектуры. Если ею не пользоваться, то ничего не могу сказать по этому вопросу.


            1. GreyN
              04.12.2025 08:11

              так где же должно быть управление транзакциями в вашей многослойной архитектуре?!
              если слой работы с Entity не знает, что потом из Entity запросят для формирования DTO, то что же он должен вытащить из БД для предотвращения появления LazyInitializationException в будущем при обращении к "ленивым" полям Entity?!

              казалось бы, ну вытаскивай joing fetch ленивые поля всегда и нет проблем!
              но они есть
              если у меня по 100 тегов у каждого продукта и я вытаскиваю 100 продуктов, значит из БД мне приедет 10000 строк.
              и зачем мне гонять эти данные, если потом при формировании DTO к коллекции тегов никто не обратится?
              а если у меня десяток ленивых полей в Entity и для разных DTO используются разные поля?

              и вот у нас уже знание о структуре DTO неявно потекло на уровень работы с Entity


          1. AlexViolin
            04.12.2025 08:11

            что же мы получим в слое работы с DTO, если заранее не вытащить join fetch Tag

            В чём проблема извлечь все объекты Tag? Дайте нужные настройки при выполнении этого запроса.


            1. GreyN
              04.12.2025 08:11

              в том, что надо знать, когда они нужны, а когда нет.
              нужно знать, в каком DTO используются поля из Tags, а в каком не используются.
              а это уже знание о структуре DTO на уровне слоя для работы с Entity


              1. AlexViolin
                04.12.2025 08:11

                Похоже не совсем понимаю проблему у автора комментария. В одном DTO добавляю поля Tags, а в другом DTO нет таких полей, если они не нужны в функционале использующем DTO.


                1. GreyN
                  04.12.2025 08:11

                  а теперь в том же DTO надо добавить новое поле, которое в Entity есть, но оно тоже ленивое. Что придется делать? Придется пойти и поправить запрос на уровне работающем с Entity!
                  Таким образом, информация о структуре DTO протекает на уровень работы с Entity.
                  И вот уже нет никакой "слоистой" архитектуры. Есть очередная "дырявая", в которой слои протекают друг в друга.


  1. pavel_shabalin
    04.12.2025 08:11

    Извините, но то, что вы описываете здесь - высосанные из пальца проблемы и бредовые бессмысленные примеры. Тут либо слабое понимание того как это вообще работает и незнание о Projections, EntityGrath. Либо просто набрасывание на вентилятор.
    И да - Pageble не работает @Query.Вы используете запрос то и используйте явные ограничения.


    1. GreyN
      04.12.2025 08:11

      с чего это вдруг Pageable не работает с Query?!

      я каждый день пишу запросы вроде

      @Query("select o.* from OrderEntity o where o.vendor = :vendor")
      Slice<OrderEntity> getAllByVendor(@Param("vendor") String vendor, Pageable pageable);

      и все прекрасно работает!
      limit/offset на месте
      вы точно мануал по spring data jpa читали?


  1. Grabr
    04.12.2025 08:11

    Давольно давно перешли на Data JDBC. В целом, мне нравится.

    Но у него есть ряд проблем/сложностей.

    Главная проблема - Criteria API. То, что он ограниченный - не страшно. Страшно то, что он закрытый для расширения. Равно, in, like - это хорошо, но современные БД - это массивы, json, полнотекстовый поиск и т.д.

    Я попробовал реализовать свой оператор для array contains в Postgres, и у меня даже получилось. Но для этого пришлось создать пакеты "org.springframework.data.*" в своем приложении и дюжину классов в них, которые экстендят стандартные (а QueryMapper пришлось вообще почти целиком скопировать с небольшими изменениями). Потому что все package-private, никакого DI - все создается явно в конструкторах.

    Другие сложности (эти уже решаемые), с которыми сталкивался:

    • enumeration в БД

    • конвертация из List в json-массив


  1. scard
    04.12.2025 08:11

    Мы в своих проектах также ушли от Spring Data JPA в пользу Spring Data JDBC. Опять же, по причине постоянного LazyInitializationException (структуры БД часто таковы, что это исключение возникает рано или поздно, и приходилось его разрешать). Но главный вопрос - производительность. С переходом на JDBC она поднялась в разы. С другой стороны, JPA позволяет очень быстро набросать проект. Потому новые проекты порою начинаются всё в том же JPA, набрасываем крупными мазками свои идеи, и затем прегоняем в JDBC там, где нам нужна скорость (частично) или полностью.