Реляционные базы данных по-прежнему остаются главным хранилищем наших данных. А значит, вопрос выбора инструмента отображения данных из БД на уровне приложения - всё так же актуален.
Долгое время я выбирал: 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; }
Код выглядит достаточно тривиально. Что тут может пойти не так? А пойти не так может буквально всё:
Возможно, всё пройдёт гладко — скидка сохранится в БД
Возможно, скидка будет установлена в объекте, но в БД ничего не попадёт
Нужно вызвать save(order), чтобы изменения сохранились
Нужно вызвать saveAll(items), чтобы изменения сохранились
LazyInitializationException
Так писать вообще нельзя — перепишите немедленно
Что произойдёт при выполнении этого кода? Зависит от контекста: как был вызван метод, как объявлены сущности и связи, в каком состоянии сейчас заказ. Вариативность зашкаливает.
В итоге мы начинаем вводить правила на уровне проекта или команды: как работать с сущностями, как их описывать. Но сколько бы правил вы ни придумали — ошибок не избежать. Вас всё равно ждут долгие часы дебага с мыслью: «Как я тут оказался и почему этот тривиальный случай не работает?»
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 для вашего приложения. Приходите, обсудим. По итогам планирую написать отдельную статью.

Однако кратко отвечу на вопросы, которые поставил в этой статье:
Сущности в Spring Data JDBC не имеют состояния
Сохранение и обновление происходит всегда предсказуемо
Проблемы ленивой загрузки связей нет по определению
Если производительность какой-то операции не устраивает — её всегда можно оптимизировать средствами Spring JDBC
В любой момент можно перейти от Spring Data JDBC к чистому Spring JDBC
