Реляционные базы данных по-прежнему остаются главным хранилищем наших данных. А значит, вопрос выбора инструмента отображения данных из БД на уровне приложения - всё так же актуален.
Долгое время я выбирал: 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
Комментарии (17)

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

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

GreyN
04.12.2025 08:11и в каком слое происходит управление транзакцией?
в слое, где находятся DTO? тогда это нарушение принципов слоеной архитектуры
в слое, где идет работа с Entity? тогда добро пожаловать в LazyInitializationExeption, либо слой работы с Entity должен как-то знать, что именно надо вытащить из БД, чтобы на слоях выше LazyInitializationExeption не возникало. а значит у нас знание о структуре DTO неявно перетекает на уровень работы с Entity.
простите, но все эти "слоеные архитектуры" - очередной marketing bullshit для продажи книг и курсов. ну и еще отличный баттлфилд для холиваров
AlexViolin
04.12.2025 08:11Отличный комментарий - смешаны транзакции, Entity, LazyInitializationException и "слоеные архитектуры" как очередной marketing bullshit.
Но мне ответить очень легко.
DTO - это не обязательно какой-то аналог Entity. DTO может включать в себя информацию из разных Entity объектов и слой, содержащий DTO, конечно не имеет никакого отношения к тому как и где реализованы транзакции.
Что касается LazyInitializationException, то совершенно не обязательно, что Entity наполняется при помощи ORM фреймворка, который вызывает упомянутую исключительную ситуацию. Но даже при использовании ORM функционал слоя работающего с Entity должен быть реализован так, чтобы исключить возникновение LazyInitializationException.
Что касается транзакций, то типовой алгоритм следующий. Допустим работа с Entity идёт в logic layer. Функционал работающий с Entity помещается внутри функционала use case или по Фаулеру это application logic (это тоже logic layer). При старте метода в объекте application logic запускается транзакция, а затем отрабатывает нужный функционал с Entity. Если при работе метода не возникли exception, то по завершению функционала метода application logic отрабатывает commit транзакции, в противном случае rollback. Возникновение LazyInitializationException откатит транзакцию, а сообщение об этой ошибке покажет разработчику её источник.

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
AlexViolin
04.12.2025 08:11Если речь идёт о работе с транзакциями, то конечно надо знать условия вызова commit/rollback.
слой работы с Entity должен неявно знать структуру будущего DTO
Слой работающий с DTO более высоко лежащий, чем слой с Entity. И как раз слой с DTO знает всё про наличие и структуру Entity. И это вполне естественно, так как DTO наполняется данными из объектов Entity. А слой с Entity ничего не знает про слой с DTO. Такова логика многослойной архитектуры. Если ею не пользоваться, то ничего не могу сказать по этому вопросу.

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

AlexViolin
04.12.2025 08:11что же мы получим в слое работы с DTO, если заранее не вытащить join fetch Tag
В чём проблема извлечь все объекты Tag? Дайте нужные настройки при выполнении этого запроса.

GreyN
04.12.2025 08:11в том, что надо знать, когда они нужны, а когда нет.
нужно знать, в каком DTO используются поля из Tags, а в каком не используются.
а это уже знание о структуре DTO на уровне слоя для работы с Entity
AlexViolin
04.12.2025 08:11Похоже не совсем понимаю проблему у автора комментария. В одном DTO добавляю поля Tags, а в другом DTO нет таких полей, если они не нужны в функционале использующем DTO.

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

pavel_shabalin
04.12.2025 08:11Извините, но то, что вы описываете здесь - высосанные из пальца проблемы и бредовые бессмысленные примеры. Тут либо слабое понимание того как это вообще работает и незнание о Projections, EntityGrath. Либо просто набрасывание на вентилятор.
И да -Pagebleне работает@Query.Вы используете запрос то и используйте явные ограничения.
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 читали?

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-массив

scard
04.12.2025 08:11Мы в своих проектах также ушли от Spring Data JPA в пользу Spring Data JDBC. Опять же, по причине постоянного LazyInitializationException (структуры БД часто таковы, что это исключение возникает рано или поздно, и приходилось его разрешать). Но главный вопрос - производительность. С переходом на JDBC она поднялась в разы. С другой стороны, JPA позволяет очень быстро набросать проект. Потому новые проекты порою начинаются всё в том же JPA, набрасываем крупными мазками свои идеи, и затем прегоняем в JDBC там, где нам нужна скорость (частично) или полностью.
excentro
Спасибо за статью. Мне интересно, а spring data jdbc хоть на сколько-то производительнее jpa?
ikuchmin Автор
К производительности иногда возникают вопросы, но она определённо более предсказуема