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

Долгое время я выбирал: 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