С точки зрения доступа к базе данных, Java-сообщество однозначно делится на два лагеря: одни любят Spring Data JPA за его простоту и низкий порог вхождения, другие предпочитают Spring JDBC за его точность и возможность тюнинга запросов. И кого останавливает, что и то, и другое — Spring?
Какую сторону выбрать? И Spring Data JPA, и Spring Data JDBC, при их очевидных достоинствах, имеют недостатки, делающие разработку на них не очень подходящей для прода. Эти решения являются двумя крайностями, а нам нужна золотая середина.
Вы спросите: какие альтернативы? И я отвечу: давайте посмотрим на проблему шире. Вы джавист? Вам повезло — есть хорошая альтернатива. Котлинист? Ещё лучше — есть отличная альтернатива!

Что мы сделаем?
Чтобы понимать, какой фреймворк считать хорошим, а какой — плохим, мы определим критерии, которые будем считать критичными для репозиторного фреймворка. И проверим кандидаты на соответствие этим требованиям. В конце у нас получится сводная таблица и «ценность» фреймворков в баллах.
Статья будет в двух частях. В первой части мы обсудим решения Spring. Во второй — разберём альтернативы.
Первое. Разработчик должен иметь контроль над запросами в базу данных
Запросы в базу данных должны полностью соответствовать ожиданиям разработчика, без неожиданного поведения и побочных эффектов. Разработчик должен иметь возможность собрать именно такой запрос, который ему нужен, и настроить его как угодно.
Почему это важно?
Всё дело — в трудностях перевода.
Наше приложение, написанное на Java, существует в парадигме объектно-ориентированного программирования. Мы оперируем классами, объектами, ссылками на них.
В реляционной базе данных реализован подход, при котором объектами являются записи базы данных, идентификаторами таких записей являются первичные ключи, ссылками на них являются ключи внешние, и все архитектурные сущности подчинены наложенным на них ограничениям (constraint).
Объектно-ориентированный язык запрограммирован настолько отлично от языка реляционного, что сложности перевода при этом неизбежны.

Именно поэтому окончательно контролировать такой перевод нужно на более высоком абстрактном уровне. И делать это должен человек. Разработчик должен иметь возможность анализировать запросы приложения в базу данных и изменять их по своему вкусу.
Второе. Репозиторный фреймворк не должен препятствовать поддержке и развитию кодовой базы
Доработки кода, такие, как расширение и изменение функциональности, рефакторинг, должны осуществляться максимально удобным для разработчика способом. Это касается и репозиторного фреймворка. Его реализация не должна останавливать разработчика от изменения кода в любой момент.
Удобство в поддержке: почему это так важно?
Главным критерием работоспособности кода, на мой взгляд, является возможность его изменения и рефакторинга в любой момент времени. Мы, разработчики, ошибаемся. И стоимость ошибки, в том числе, зависит от стоимости её исправления. Как только мы видим ошибку в коде, мы должны её исправить. Или запланировать исправление на самое ближайшее время (для соблюдения этого правила мы, например, добавляем в TODO номер задачи, в рамках которой ошибка должна быть исправлена).

Мы, люди, существа слабые. И мы избегаем неприятных для нас вещей. Мы найдём миллион причин, почему именно сегодня этот баг не нужно исправлять. И не исправим, если это будет сопряжено с трудностями.
Ну, а дальше вы знаете: обрастание кода костылями (костыль ведь всегда дешевле, чем нормальный рефакторинг, ведь правда, правда?), переход проекта в жёлтую фазу, потом в коричневую, последующее превращение в Большой Ком Грязи и бегство с проекта ключевых разработчиков.

Пришёл я в одну команду однажды. Дождался планирования.
Аналитик берёт слово: «Давайте в этом спринте реализуем эту фичу». Разработчик: «Это слишком дорого. Мы не успеем в спринт». Аналитик: «Хорошо, давайте тогда вот эту?» Разработчик: «Это тоже слишком дорого».
...
Они так и не договорились на спринт. Любые, даже самые маленькие фичи оказывались слишком «дорогими». Конечно, этому способствовало общее состояние кодовой базы. И, как потом выяснилось, в значительной части этому поспособствовал выбор репозиторного фреймворка.

Мы же не хотим этого, правда? А раз не хотим, то нужно обращать на это внимание прямо на этапе выбора репозиторного фреймворка.
Третье. Соблюдение чистой архитектуры.
Здесь требование одно, но очень важное: вся работа с фреймворком должна инкапсулироваться в репозиторном слое. С точки зрения доступа к данным слой DAL (Data Access Layer) должен зависеть от предметной области приложения, но не наоборот. Предметная область приложения и обслуживающий её сервисный слой не должны ничего знать про внешние интерфейсы, к которым они подключаются.
Почему это важно?
Слово Роберту Мартину:
Цель архитектуры программного обеспечения — уменьшить человеческие трудозатраты на создание и сопровождение системы.
Иными словами, чистая архитектура — это деньги, сэкономленные заказчиком. Но при чём здесь Data Access Layer и инкапсуляция репозиторного слоя в его пределах? А вот при чём.

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

Чтобы предметные области не перемешивались и не влияли друг на друга, их нужно разделять через абстракцию. В противном случае, предметные области начнут в той или иной степени влиять на вашу предметную область. А ваша предметная область начнёт влиять на предметные области подключаемых к ней сервисов. Из-за высокой связности системы станут жёсткими — ваш сервис должен будет учитывать любое изменение в системе, которая пустила в нём корни своей предметной областью (подробнее можно прочитать в моей статье «Domain-Driven Design: чистая архитектура снизу доверху»).
Очень часто команды начинают проектирование сервиса c проектирования базы данных. Это ошибочный подход, поскольку база данных является для сервиса внешним источником, со своей предметной областью. Таким образом, предметная область базы данных начинает влиять на предметную область приложения, поскольку вы уже при проектировании завязываете свой сервис на архитектуру базы данных.
Да, такой риск может быть оправдан, особенно если база данных может обслуживать только ваш сервис, но концептуально она — всё равно отдельный сервис со своей предметной областью, и в перспективе к ней, как к самостоятельному сервису, могут подключаться другие сервисы.
Проектирование сервиса нужно начинать со своей предметной области, и более подробно это описано в статье «Приложение от проекта до релиза: этапы реализации».
Повторим три основных критерия:
Контроль над запросами.
Удобство сопровождения кода.
Соблюдение чистой архитектуры.
Теперь давайте проверим соответствием им фреймворков Spring. И начнём со Spring Data JPA.

Теперь, когда мы определились с критериями, давайте разберём фреймворки Spring на соответствие. И начнём со Spring Data JPA.
Пара слов о Spring Data JPA
Я сомневаюсь, что кто-то в Java-разработке не знает, что такое Spring Data JPA. Согласно опросу в сообществе Spring АйО, 74 % опрошенных используют Spring Data JPA в текущем проекте.

Spring Data JPA стремится к максимальной автоматизации запросов, предоставляя разработчику интуитивно понятный API для работы с базой данных. При этом фреймворк старается сделать базу данных как можно более абстрактной для разработчика, скрывая информацию о ней. Философия Spring Data JPA декларирует:
«Работайте с объектами, а всеми вопросами хранения данных буду заниматься я».
Все типовые запросы уже реализованы на уровне интерфейсов, которые нужно просто объявить. Их работа отшлифована десятилетиями отладки. У разработчика может возникнуть впечатление, будто базы данных действительно нет.
Типичный репозиторий выглядит так:
interface RestaurantRepository : JpaRepository<Restaurant, UUID>
и прямо «из коробки» содержит в себе всё впечатляющее количество функций управления данными. Над реализацией была проделана колоссальная работа.
Но давайте посмотрим, насколько Spring Data JPA соответствует нашим требованиям к репозиторным фреймворкам.
Насколько Spring Data JPA соответствует нашим требованиям?
Как сказано выше, Spring Data JPA лидирует в качестве репозиторного фреймворка в текущих проектах на JVM. Но прежде чем рассматривать его, познакомимся с базой данных. Она будет самая простая из возможных. В качестве проекта я выбрал ресторанный агрегатор. Здесь будут пользователи с ролевыми связями по модели Many-To-Many в рамках Join-таблицы. Также будут рестораны, в них будут блюда, а заказывать блюда будут пользователи. Вот и вся база.

Структуру базы и тестовые данные можно посмотреть здесь.
Что ж, вернёмся к Spring Data JPA.
Spring Data JPA. Удобство сопровождения кода
Первое, и самое главное, на мой взгляд, достоинство: практически нулевой порог вхождения. Spring Data JPA полностью отстраняет разработчика от базы данных, декларируя:
«Забудьте про базу данных, работайте с объектами. Хранение объектов я беру на себя».
И предоставленная функциональность действительно позволяет полностью забыть про базу данных, работая с объектами. Вы можете посадить за код любого джуна, который уже кое-как разбирается в Java, но понятия не имеет о базе данных, и он сможет писать рабочий код. И самое неприятное, что Spring Data JPA позаботится о том, чтобы этот код был полностью рабочим — здесь у фреймворка всё отлажено.
Да, в основном это будут запросы findAll
и findById
, но при умелом жонглировании этими двумя функциями можно получить из базы данных практически любые данные. А на трёх записях в локальной базе эти запросы выдержат любую нагрузку.
Когда я работал в заказной разработке, у меня был такой случай. Я встретил своего коллегу, мидла, как и я, и поинтересовался, как у него дела. Он ответил что-то вроде: «Я устал проверять код джунов. Здесь сплошные стримы!» На дворе стоял 2019 год, Java 8 наконец-то вошла в силу, и лямбды были очень модным решением. Я удивился: «Чем тебе стримы-то не нравятся?» — «Да нет, стримы-то мне нравятся! Но ведь они делают findAll на всю таблицу безо всякой фильтрации и потом уже фильтруют нужные им данные через стримы!»

Давайте разберём этот случай на имеющейся базе данных. Мы напишем запрос, который будет получать какую-то выборку и фильтровать её в соответствии с параметрами.
Проект с примерами по Spring Data JPA.
Например, нам нужно получить список ресторанов, в которых хотя бы раз был пользователь с ролью ADMIN. Такой запрос в его самой простой форме (и будьте уверены, что джун соблазнится написать именно такой) будет выглядеть так:
@Transactional
override fun getAllRestaurantNamesOrderedByAdmins(): List<String> =
repository
.findAll() //получили выборку, здесь мы получили все имеющиеся, но это не принципиально
.filter { restaurant ->
restaurant
.dishes //получили все блюда
.flatMap { it.orders } //получили все заказы по блюдам
.map { it.user } //получили пользователей
.flatMap { it!!.roles }
.any { it.name == RoleType.ADMIN } //оставили только админов
}
.sortedBy { it.name }
.map { it!!.name!! }
Что мы сделали? Мы получили некоторое количество ресторанов,все блюда,все заказы. Получили пользователей, оставили только админов. Вернули отфильтрованные рестораны.
В проекте этот код лежит здесь.
Если выполнить его и включить журналирование запросов, то мы увидим целых 34 select-а к базе данных:
Скрытый текст
Hibernate:
select
r1_0.id,
r1_0.name
from
restaurants r1_0
Hibernate:
select
d1_0.restaurant_id,
d1_0.id,
d1_0.active,
d1_0.name,
d1_0.price,
d1_0.version
from
dishes d1_0
where
d1_0.restaurant_id=?
Hibernate:
select
o1_0.dish_id,
o1_0.id,
o1_0.date,
o1_0.user_id
from
orders o1_0
where
o1_0.dish_id=?
Hibernate:
select
o1_0.dish_id,
o1_0.id,
o1_0.date,
o1_0.user_id
from
orders o1_0
where
o1_0.dish_id=?
Hibernate:
select
o1_0.dish_id,
o1_0.id,
o1_0.date,
o1_0.user_id
from
orders o1_0
where
o1_0.dish_id=?
Hibernate:
select
o1_0.dish_id,
o1_0.id,
o1_0.date,
o1_0.user_id
from
orders o1_0
where
o1_0.dish_id=?
Hibernate:
select
o1_0.dish_id,
o1_0.id,
o1_0.date,
o1_0.user_id
from
orders o1_0
where
o1_0.dish_id=?
Hibernate:
select
u1_0.id,
u1_0.password,
u1_0.username
from
users u1_0
where
u1_0.id=?
Hibernate:
select
r1_0.user_id,
r1_1.id,
r1_1.name
from
users_roles r1_0
join
roles r1_1
on r1_1.id=r1_0.role_id
where
r1_0.user_id=?
Hibernate:
select
u1_0.id,
u1_0.password,
u1_0.username
from
users u1_0
where
u1_0.id=?
Hibernate:
select
r1_0.user_id,
r1_1.id,
r1_1.name
from
users_roles r1_0
join
roles r1_1
on r1_1.id=r1_0.role_id
where
r1_0.user_id=?
Hibernate:
select
u1_0.id,
u1_0.password,
u1_0.username
from
users u1_0
where
u1_0.id=?
Hibernate:
select
r1_0.user_id,
r1_1.id,
r1_1.name
from
users_roles r1_0
join
roles r1_1
on r1_1.id=r1_0.role_id
where
r1_0.user_id=?
Hibernate:
select
u1_0.id,
u1_0.password,
u1_0.username
from
users u1_0
where
u1_0.id=?
Hibernate:
select
r1_0.user_id,
r1_1.id,
r1_1.name
from
users_roles r1_0
join
roles r1_1
on r1_1.id=r1_0.role_id
where
r1_0.user_id=?
Hibernate:
select
u1_0.id,
u1_0.password,
u1_0.username
from
users u1_0
where
u1_0.id=?
Hibernate:
select
r1_0.user_id,
r1_1.id,
r1_1.name
from
users_roles r1_0
join
roles r1_1
on r1_1.id=r1_0.role_id
where
r1_0.user_id=?
Hibernate:
select
d1_0.restaurant_id,
d1_0.id,
d1_0.active,
d1_0.name,
d1_0.price,
d1_0.version
from
dishes d1_0
where
d1_0.restaurant_id=?
Hibernate:
select
o1_0.dish_id,
o1_0.id,
o1_0.date,
o1_0.user_id
from
orders o1_0
where
o1_0.dish_id=?
Hibernate:
select
o1_0.dish_id,
o1_0.id,
o1_0.date,
o1_0.user_id
from
orders o1_0
where
o1_0.dish_id=?
Hibernate:
select
o1_0.dish_id,
o1_0.id,
o1_0.date,
o1_0.user_id
from
orders o1_0
where
o1_0.dish_id=?
Hibernate:
select
o1_0.dish_id,
o1_0.id,
o1_0.date,
o1_0.user_id
from
orders o1_0
where
o1_0.dish_id=?
Hibernate:
select
o1_0.dish_id,
o1_0.id,
o1_0.date,
o1_0.user_id
from
orders o1_0
where
o1_0.dish_id=?
Hibernate:
select
d1_0.restaurant_id,
d1_0.id,
d1_0.active,
d1_0.name,
d1_0.price,
d1_0.version
from
dishes d1_0
where
d1_0.restaurant_id=?
Hibernate:
select
d1_0.restaurant_id,
d1_0.id,
d1_0.active,
d1_0.name,
d1_0.price,
d1_0.version
from
dishes d1_0
where
d1_0.restaurant_id=?
Hibernate:
select
d1_0.restaurant_id,
d1_0.id,
d1_0.active,
d1_0.name,
d1_0.price,
d1_0.version
from
dishes d1_0
where
d1_0.restaurant_id=?
Hibernate:
select
d1_0.restaurant_id,
d1_0.id,
d1_0.active,
d1_0.name,
d1_0.price,
d1_0.version
from
dishes d1_0
where
d1_0.restaurant_id=?
Hibernate:
select
o1_0.dish_id,
o1_0.id,
o1_0.date,
o1_0.user_id
from
orders o1_0
where
o1_0.dish_id=?
Hibernate:
select
o1_0.dish_id,
o1_0.id,
o1_0.date,
o1_0.user_id
from
orders o1_0
where
o1_0.dish_id=?
Hibernate:
select
o1_0.dish_id,
o1_0.id,
o1_0.date,
o1_0.user_id
from
orders o1_0
where
o1_0.dish_id=?
Hibernate:
select
o1_0.dish_id,
o1_0.id,
o1_0.date,
o1_0.user_id
from
orders o1_0
where
o1_0.dish_id=?
Hibernate:
select
o1_0.dish_id,
o1_0.id,
o1_0.date,
o1_0.user_id
from
orders o1_0
where
o1_0.dish_id=?
Hibernate:
select
d1_0.restaurant_id,
d1_0.id,
d1_0.active,
d1_0.name,
d1_0.price,
d1_0.version
from
dishes d1_0
where
d1_0.restaurant_id=?
Hibernate:
select
d1_0.restaurant_id,
d1_0.id,
d1_0.active,
d1_0.name,
d1_0.price,
d1_0.version
from
dishes d1_0
where
d1_0.restaurant_id=?
Почему это произошло?
Как мы знаем, фильтрация — это вызов каждого элемента списка в цикле и его проверка на соответствие предикату. Хорошей практикой связывания объекта и таблицы является «ленивая» инициализация, именно поэтому у нас все поля ленивые. Мы же не хотим обязательных лишних запросов.
И вот мы получили ресторан и решили проверить его на соответствие условию (ступала ли в него нога админа). Мы запрашиваем все блюда для этого ресторана — это запрос. Для каждого блюда мы запрашиваем заказы, которые были для него сделаны — ещё один запрос. Для каждого заказа мы запрашиваем пользователя, который его сделал — снова запрос. Для пользователя мы запрашиваем его роли — опять запрос.
Всего получилось 34 select-а к базе с 41 записью (по всем таблицам). Философия Spring Data JPA призывала нас забыть о существовании базы данных и работать с объектами. Мы забыли и работаем. А в это время, при простой фильтрации, фреймворк безостановочно молотит запросы к базе данных
В той же компании мне пришлось исправлять дефект в коде, написанном джуном.
Дефект заключался в том, что один несложный запрос выполнялся 40 секунд, а второй съедал 50 мегабайтов оперативки. Каждый. Я заглянул в код, и в обоих случаях увидел вышеописанную картину: содержимое всей таблицы выгребается в оперативку, а потом начинается фильтрация.
Коллеги использовали этот запрос для получения только одного элемента (они не знали про существование findById).

И даже если вы будете очень аккуратны с запросами, где-нибудь всё равно пропустите запрос, который будет инициализацией ленивых полей съедать половину ресурсов ЦОДа — Spring Data JPA обеспечивает для этого все условия.
Впрочем, если отбросить проблемы чрезмерного потребления ресурсов, с основной обсуждаемой здесь задачей — удобством сопровождения кода — Spring Data JPA справляется блестяще.
Что дальше?
Spring Data JPA. Контроль над запросами

А здесь всё намного хуже. Вы не можете в полной мере, используя магистральное решение Spring Data JPA (опустим пока аннотацию Query, до неё мы ещё доберёмся), контролировать запросы, которые вы пишете. Выше мы уже обсуждали ситуацию, когда запросы выполняются не тогда, когда этого ожидаете вы, а когда их считает нужным выполнить фреймворк. И вам будет очень сложно обуздать лишние запросы, поскольку сама философия Spring Data JPA абстрагирует вас от базы данных и требует, чтобы вы туда не лезли.
С другой стороны, никто не заставлял вас играть в эту игру. Вы выбрали её самостоятельно, так что примите её правила. Довольно странным решением будет сначала выбрать фреймворк, а потом всеми путями пытаться обойти его философию.
Да, это проблема N + 1. И в Spring Data JPA она встаёт во весь рост. С одной стороны, фреймворк решает, где и какие запросы выполнять. Но, с другой стороны, это всего лишь фреймворк. Которому вы доверили ключи от производительности.
Разберём пример. Переходим в место в проекте со следующим кодом. У нас есть тест, который проверяет обновление имени блюда:
@Test
@Transactional
@DisplayName("update(): неявное поведение: upload то работает, то нет")
fun update() {
//given
val id = UUID.fromString("2f3cedce-ad2d-4782-98c6-c0f62b8a3f5c")
val dish = repository.findById(id)
.orElseThrow { IllegalStateException("Dish not found by id: $id") }
val newName = "Мясо по-бразильски + ${RandomStringUtils.secure().nextAlphanumeric(32)}"
dish?.name = newName
//when
repository.save(dish)
//then
repository.findById(id).also { assertEquals(newName, dish.name) }
}
Всё очевиднее некуда. Мы берём блюдо по идентификатору, оно есть в базе данных. После этого мы меняем значение поля name
(я использовал 32-символьное случайное окончание, чтобы быть уверенным, что новое имя будет уникальным). После этого мы обновляем сущность при помощи функции save
(функции update
в Spring Data JPA нет). В итоге, мы удостоверяемся, что поле обновилось.
Я ожидаю, что выполнится 3 запроса:
Получение объекта из базы данных по идентификатору.
Обновление поля объекта в базе данных.
Повторное получение объекта из базы данных для того, чтобы удостовериться, что поле name обновилось.
Я ожидаю вызова именно этих запросов на том основании, что я, как разработчик, последовательно вызвал репозиторные функции с очевидными названиями: findById
, save
и ещё раз findById
.
Что ж, запустим тест и посмотрим, какие запросы выполнятся. Логи нас в значительной степени удивят:
Скрытый текст
Hibernate:
select
d1_0.id,
d1_0.active,
d1_0.name,
d1_0.price,
d1_0.restaurant_id,
d1_0.version
from
dishes d1_0
where
d1_0.id=?
Выполнился только первый select
. Мы получили объект из базы данных, и ничего не произошло. Ни обновления объекта, ни повторного получения объекта с целью сравнения полей.
Почему?
Здесь всё просто. Мы поставили над тестом аннотацию Transactional
. А мы знаем, что при использовании этой аннотации коммит не применяется. Хорошо, давайте вызовем другую функцию: saveAndFlush
. Она гарантирует коммит в базу данных.
@Test
@Transactional
@DisplayName("update(): неявное поведение: upload то работает, то нет")
fun update() {
//given
val id = UUID.fromString("2f3cedce-ad2d-4782-98c6-c0f62b8a3f5c")
val dish = repository.findById(id)
.orElseThrow { IllegalStateException("Dish not found by id: $id") }
val newName = "Мясо по-бразильски + ${RandomStringUtils.secure().nextAlphanumeric(32)}"
dish?.name = newName
//when
repository.saveAndFlush(dish)
//then
repository.findById(id).also { assertEquals(newName, dish.name) }
}
Запустим тест повторно. Логи на этот раз будут такими:
Скрытый текст
Hibernate:
select
d1_0.id,
d1_0.active,
d1_0.name,
d1_0.price,
d1_0.restaurant_id,
d1_0.version
from
dishes d1_0
where
d1_0.id=?
Hibernate:
update
dishes
set
active=?,
name=?,
price=?,
restaurant_id=?,
version=?
where
id=?
and version=?
Теперь мы видим один select
и один update
. Но второй select
, который удостоверит нас в том, что имя успешно записалось в базу данных, по-прежнему отсутствует.
Почему?
Потому что фреймворк закешировал объект при сохранении. И сравнил первоначальный объект не с заново полученным из базы данных, а с закешированным экземпляром. И посчитал, что второй запрос выполнять не обязательно.
Хорошо, давайте всё-таки попытаемся добиться вызова именно тех запросов, которые мы описали в коде. У нас по-прежнему три вызова репозиторных функций, и, возможно, с третьего раза нам удастся вызвать именно те, которые мы вызываем из кода.
Для достижения требуемого результата мы пойдём на крайнюю меру: уберём аннотацию Transactional
.
@Test
@DisplayName("update(): неявное поведение: upload то работает, то нет")
fun update() {
//given
val id = UUID.fromString("2f3cedce-ad2d-4782-98c6-c0f62b8a3f5c")
val dish = repository.findById(id)
.orElseThrow { IllegalStateException("Dish not found by id: $id") }
val newName = "Мясо по-бразильски + ${RandomStringUtils.secure().nextAlphanumeric(32)}"
dish?.name = newName
//when
repository.saveAndFlush(dish)
//then
repository.findById(id).also { assertEquals(newName, dish.name) }
}
Запустим наш тест. Смотрим логи:
Скрытый текст
Hibernate:
select
d1_0.id,
d1_0.active,
d1_0.name,
d1_0.price,
d1_0.restaurant_id,
d1_0.version
from
dishes d1_0
where
d1_0.id=?
Hibernate:
select
d1_0.id,
d1_0.active,
d1_0.name,
d1_0.price,
d1_0.restaurant_id,
d1_0.version,
o1_0.dish_id,
o1_0.id,
o1_0.date,
o1_0.user_id
from
dishes d1_0
left join
orders o1_0
on d1_0.id=o1_0.dish_id
where
d1_0.id=?
Hibernate:
update
dishes
set
active=?,
name=?,
price=?,
restaurant_id=?,
version=?
where
id=?
and version=?
Hibernate:
select
d1_0.id,
d1_0.active,
d1_0.name,
d1_0.price,
d1_0.restaurant_id,
d1_0.version
from
dishes d1_0
where
d1_0.id=?
К счастью, у нас появился наконец-то третий select
. Но их теперь у нас три — перед update
появился ещё один select
, да ещё и с join
-ом. Но откуда?
Дело в том, что, как уже было сказано ранее, Spring Data JPA не имеет функции update
. И, получив объект для сохранения, он сначала определяет, новый ли это объект или нет.
@Transactional
public <S extends T> S save(S entity) {
Assert.notNull(entity, "Entity must not be null");
if (this.entityInformation.isNew(entity)) {
this.entityManager.persist(entity);
return entity;
} else {
return (S)this.entityManager.merge(entity);
}
}
Если поле id
у объекта отсутствует, то он считается новым и для него вызывается метод persist
. Если же поле есть, то вызывается метод merge
. При этом из базы извлекается уже имеющийся в ней объект с таким id
, в котором обновляются поля и он записывается в базу данных. Таким образом, при обновлении сущности происходит лишний запрос.
Я попытался вызвать ожидаемые запросы тремя разными способами, но мне это так и не удалось. Возможно, удастся вам. Но задача, как мы видим, нетривиальная, и архитектура фреймворка не предполагает простых решений. Просто примите это. Выбирая Spring Data JPA, вы отказываетесь от полного контроля запросов к базе данных. Для высоконагруженных запросов это может стать критичным препятствием.
Но как же Query?
Да, Query отчасти спасает ситуацию. Мы можем написать SQL-запрос «поверх» вызываемой функции, что даёт некоторую возможность контроля запросов со стороны разработчика. Но у такого подхода есть и недостатки.
Подход является, по сути, обходным путём для основного решения. Это говорит о том, что разработчики фреймворка признают архитектурные проблемы, которые заложены в их решении, и стараются дать спасительную таблетку в виде альтернативы, причём прямо в целевом решении.
Поскольку JPA-репозитории используют декларативный подход, объявляются через интерфейсы и не имеют реализации, такие запросы могут быть написаны в очень жёстких рамках, регулируемых параметрами аннотации. Вы не можете добавить дополнительную логику обработки запроса, поскольку функциональность Query ограничена параметрами. Используемая реализация через внедрение запроса в виде строкового литерала имеет свои недостатки, но об этом ниже.
Если подвести итог, то в части контроля над запросами Spring Data JPA очень сильно проигрывает. Зафиксируем это и пойдём дальше.
Spring Data JPA. Соблюдение чистой архитектуры.
Здесь тоже не всё так хорошо. Ранее мы уже выяснили, почему репозиторий должен быть инкапсулирован в репозиторном слое. Такой подход гарантирует независимость предметной области приложения от влияния внешних источников данных, коим является и база данных. Допуская её влияние на предметную область приложения, мы подписываемся учитывать дальнейшие изменения в базе данных в предметной области приложения и подстраивать её под эти изменения. Это гарантирует ещё и веерные изменения в бизнес-логике. Приложение получит дополнительную неподвижность на ровном месте.
Типичная бизнес-сущность, скрещённая с репозиторной и инфицированная репозиторными аннотациями, выглядит так:
@Entity
@Table(name = "dishes")
class Dish(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: UUID? = null,
@Column(name = "name")
var name: String? = null,
@Column(name = "price")
var price: BigDecimal? = null,
@Column(name = "active")
var active: Boolean? = null,
@Version
var version: Int? = null,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "restaurant_id", nullable = false)
val restaurant: Restaurant? = null,
@OneToMany(mappedBy = "dish", fetch = FetchType.LAZY, cascade = [CascadeType.ALL], orphanRemoval = true)
val orders: List<Order> = listOf()
)
Мы знаем практически обо всех атрибутах таблицы: названии таблицы, идентификаторе, стратегии генерирования, именах колонок, связях с другими таблицами и прочем. Эти знания способны серьёзно испортить нам жизнь.
В дополнение к этому, такая связь накладывает на нас некоторые архитектурные обязательства, которые нам в проекте как бы и не особо были нужны:
Репозиторная сущность должна быть открытой для наследования.
Репозиторная сущность должна иметь публичный конструктор.
Репозиторная сущность должна быть изменяемой.
и многое другое, что вполне может быть лишним в архитектуре ваших бизнес-сущностей, но вы должны внедрять их в угоду JPA. Каждый из этих пунктов имеет свои недостатки и при определённых обстоятельствах может стать архитектурным кошмаром.
Решения?
Они есть. Самым очевидным было бы создание отдельной репозиторной сущности с последующим проецированием её полей на поля доменной сущности.

Но и у этого подхода есть проблемы. Функциональность Spring Data JPA реализована через интерфейсы, которые возвращают репозиторную сущность. И если сервис будет вызывать интерфейс, реализующий JpaRepository напрямую, то он будет получать репозиторную сущность, чего тоже нельзя допускать: согласно луковичной архитектуре, сервис работает только с предметной областью своего приложения и не должен ничего знать о предметных областях внешних источников данных.

Как быть? Решение напрашивается в виде абстракции между сервисом и репозиторием, и здесь команды обычно сдаются. Разделить модели на бизнес-сущности и репозиторные сущности — ещё ладно, но когда речь заходит о дополнительном абстрактном слое, единственная задача которого заключается в проецировании репозиторной сущности в бизнес-сущность и обратно, то команды приходят к выводу, что прямое проецирование репозиторной сущности на бизнес-сущности через аннотации уже не выглядит таким безобразным.
Таким образом, в части соблюдения чистой архитектуры Spring Data JPA имеет определённые проблемы.
Spring Data JPA. Выводы
Таким образом, Spring Data JPA полностью соответствует только одному требованию из трёх: благодаря продуманной архитектуре и отточенной функциональности, фреймворк очень хорошо подходит для быстрого запуска небольшого проекта на этапе MVP, что делает его крайне удобным для поддержки. Что касается управления запросами к базе данных, то разработчик не может управлять такими запросами в полной мере. Чистую архитектуру фреймворк нарушает, подминая бизнес-сущность под свои нужды.

Таким образом, Spring Data JPA максимально отстраняет разработчика от управления запросами к базе данных, предлагая взамен философию «никакой базы данных нет».
Окей, мы разобрали Spring Data JPA. Да, он очень плохо подходит для проектов, в которых важна настройка запросов. Но есть же Spring JDBC, который реализует противоположный подход, отдавая в руки разработчика практически все инструменты управления запросами. Давайте обратим внимание на этот фреймворк и разберём его на соответствие нашим требованиям.
Spring JDBC. Пара слов о фреймворке
Хоть Spring JDBC менее популярен среди Java-разработчиков, чем Spring Data JPA (основываясь на приведённом выше опросе), тем не менее он реализует подход, радикально отличающийся от реализуемого в Spring Data JPA. Никакой автоматизации. Минимум абстракции. За все запросы отвечают только разработчики. Они пишут SQL-запросы, которые вставляются прямо в код на Java в виде строк.
Подход радикально отличный, а значит, справедливо было бы ожидать, что и нашим требованиям фреймворк будет соответствовать радикально иначе.
Код проекта лежит здесь.
Spring JDBC. Контроль над запросами
И в этом пункте Spring JDBC имеет полное и безусловное преимущество над Spring Data JPA. Поскольку запросы пишутся прямо на языке SQL, мы, как разработчики, можем написать абсолютно любой запрос, и он исполнится целиком в соответствии с нашими планами. Трудности перевода при этом отсутствуют. Побочные эффекты при этом отсутствуют. Разработчик делает запросы к базе данных на её языке прямо из приложения. Что может быть лучше?
override fun getAllByUserOrdered(userId: UUID): List<Restaurant> =
jdbcTemplate.query(
"""
SELECT restaurants.id, restaurants.name
FROM restaurants
RIGHT JOIN dishes ON restaurants.id = dishes.restaurant_id
RIGHT JOIN orders ON dishes.id = orders.dish_id
WHERE orders.user_id = :userId
""".trimIndent(),
mapOf("userId" to userId)
) { rs, _ -> rs.toRestaurant() }
В Spring JDBC запрос к базе данных — это просто SQL-запрос.
Но и у такого противоположного подхода есть свои существенные недостатки, суть которых мы раскроем при разборе других требований к фреймворку.
В части контроля над запросами, Spring JDBC полностью нас устраивает.
Spring JDBC. Удобство в поддержке кода

В плане удобства в поддержке кода мы сталкиваемся со всеми теми недостатками, которые даёт нам фреймворк, предоставляя разработчику полный и безусловный контроль над запросами и перекладывая ответственность за них на плечи разработчика.
Недостаток первый. Отсутствие типобезопасности, необходимость контроля над синтаксисом

Да, Spring JDBC полностью игнорирует type safety. Если вы допускаете синтаксическую ошибку в коде, то узнаёте об этом только при исполнении этого кода. Для приложения ваш SQL-запрос является чуждым, Java-приложение не понимает его. С точки зрения приложения, это всего лишь строковый литерал, который фреймворк передаёт в коннектор «не глядя» в коннектор практически «как есть».
override fun getAllByUserOrdered(userId: UUID): List<Restaurant> =
jdbcTemplate.query(
"""
SELECT restaurants.id, restaurants.name
FROM restaurants
RIGHT JOIN dishes ON restaurants.id = dishes.restaurant_id
RIGHT JOIN orders ON dishes.id = orders.dish_id
WHERE orders.user_id = :userId
""".trimIndent(), //с точки зрения фреймворка, это просто строка.
mapOf("userId" to userId)
) { rs, _ -> rs.toRestaurant() }
Вы можете написать в строке запроса любой текст, и фреймворк отправит такой запрос на исполнение безо всякой проверки . О том, насколько он корректен, вы узнаете только на этапе исполнения запроса, уже после компиляции (которая пройдёт успешно и без ошибок).
override fun getAllByUserOrdered(userId: UUID): List<Restaurant> =
jdbcTemplate.query(
"я люблю хачапури", //такой "запрос" без проблем скомпилируется
mapOf("userId" to userId)
) { rs, _ -> rs.toRestaurant() }
Как можно уменьшить эффект от отсутствия контроля со стороны фреймворка? Только постом и молитвой полным покрытием таких запросов тестами с реальными запросами в базу данных. Вы можете называть такие тесты как угодно: модульными или интеграционными — не важно. Важно, что если решили выбрать в качестве репозиторного фреймворка Spring JDBC, то только полное покрытие тестами в какой-то мере оградит ваш код от зашкаливающего количества багов. Тесты должны покрывать как позитивные сценарии, так и негативные.

О важности и простоте разработки через тестирование можно почитать другую мою статью: «Test-Driven Development: как полюбить модульное тестирование». В ней нет полного разбора покрытия тестами репозиториев, но если вы пока не любите модульные тесты или не понимаете их значимости для проекта, статья поможет вам принять правильное решение и начать их писать :)
Минус второй. Сложности с пониманием структуры базы данных.
Примите тот факт, что ваше приложение имеет критически мало информации о структуре базы данных. Репозитории имеют очень опосредованную информацию о структуре, которую можно извлечь из запросов (если они правильно написаны, хехе).
Маперы имеют чуть больше информации о структуре, но только в рамках используемых ими колонок. Мы получаем ResultSet
«как есть», и на свой страх и риск пытаемся извлечь значения из колонок. Но мы не владеем информацией о полной структуре таблицы.
fun ResultSet.toRestaurant(): Restaurant = Restaurant(
id = this.getString("id").let { UUID.fromString(it) },
name = this.getString("name")
)
Из приведённого примера мы знаем, что в таблице restaurants (название мы подсмотрели в репозитории) есть поля id
и name
. Поле id
является UUID
, а поле name
— строкой. Это всё, что мы знаем о таблице restaurants
.
С другой стороны, в сокрытии информации о внешнем источнике данных нет ничего плохого, и в определённой мере это хорошо, поскольку разграничивает слои приложения в части данных. Но именно репозиторий, в силу специфики своей работы, должен обладать этой информацией в полной мере.
И с этим у фреймворка большие сложности.
Недостаток третий. Высокая стоимость рефакторинга
Корни этого недостатка лежат в первом недостатке, но важно обратить на него отдельное внимание. При выборе Spring JDBC рефакторинг существующего кода — столь же кропотливая задача, что и написание новых запросов.
Если, не дай Бог, в базе данных поменялась структура, была создана новая таблица или переименованы колонки, то вы не сможете привычным жестом, нажав Shift + F6, изменить наименование колонки. Как мы рассмотрели выше, в вашем проекте нет никаких табличных колонок, и это хорошо для архитектуры, но очень плохо для разработки.
Проводя рефакторинг, вам придётся вручную выискивать по всему проекту старые колонки, связи, имена таблиц и вручную актуализировать их значения. И ладно бы речь шла о каком-то одном репозитории. Но зачастую структуры данных в запросах переплетаются, таблицы соединяются друг с другом, вызываются в подзапросах, в выборку по запросу попадают поля из разных таблиц. Это всё значительно усложняет и без того сложный рефакторинг, и в больших проектах превращает его в ад.
Недостаток четвёртый. Один проект — один диалект
Поскольку все запросы пишутся вручную, вы можете использовать любой диалект. И наверняка вам захочется использовать всю силу выбранного вами диалекта, выйдя за рамки типичных для SQL запросов и используя специфичные. А это значит, что вы никогда не сможете поменять СУБД.
Да, может показаться, что выход есть: ведь можно создать параллельно несколько реализаций каждого репозитория по количеству используемых диалектов и переключаться между ними при помощи профилирования. Но такой подход будет стоить космически дорого: с поддержкой одного диалекта бы справиться; эффективно поддерживать (контроль над синтаксисом, рефакторинг, сложности с пониманием базы данных) такой проект практически невозможно.
Остаётся констатировать, что в части удобства в поддержке кода Spring JDBC имеет вполне конкретные недостатки и неудобства.
Spring JDBC. Соблюдение чистой архитектуры
В плане соблюдения чистой архитектуры, у Spring JDBC дела обстоят значительно лучше, чем у Spring Data JPA. Никто не мешает нам, разработчикам, инкапсулировать всю репозиторную часть в отдельный пакете. Ни один другой компонент приложения не будет знать о работе с базой данных решительно ничего. Всё взаимодействие с репозиторием будет заключаться в вызове нужного репозиторного интерфейса и получении интересующих нас бизнес-сущностей.
@Service
class RestaurantServiceImpl(
private val repository: RestaurantRepository
) : RestaurantService {
override fun save(restaurant: Restaurant): UUID = repository.insert(restaurant)
override fun update(restaurant: Restaurant) {
repository.update(restaurant)
}
override fun get(id: UUID): Restaurant = repository.findById(id)
?: throw IllegalStateException("Restaurant not found by id: $id")
override fun delete(id: UUID) {
repository.deleteById(id)
}
}
Spring JDBC даёт нам все возможности инкапсулировать репозиторный код таким образом, что о деталях его реализации не должен будет знать ни один компонент извне (как в примере выше).
Spring JDBC. Выводы

Да, Spring JDBC реализован радикально иначе, чем Spring Data JPA. И его инструментарий для доступа к данным также радикален. Мы, разработчики, получаем полностью контролируемый инструмент доступа к данным, за реализацию которого мы несём полную и безусловную ответственность. Нам придётся пожертвовать типобезопасностью, согласиться на очень сложные и дорогие рефакторинги и обязать себя покрыть проект тестами на базу данных (если мы хотим быть уверены, что наши запросы рабочие; фреймворк этого не гарантирует от слова «никак»). Проект будет жёстко привязан к одному диалекту, что, конечно, не добавит подвижности в реализации. О структуре базы данных наш репозиторий будет иметь крайне неполную и опосредованную информацию.
Также, взамен мы получим возможность соблюсти чистую архитектуру, инкапсулируя репозиторный слой в своём пакете.
Таким образом, Spring JDBC даёт нам максимальную свободу и не несёт никакой ответственности за результат.
А что со Spring Data JDBC? Почему не разобрали?
Отдельно разбирать Spring Data JDBC я не стал, и вот почему. И Spring Data JPA, и Spring JDBC следуют абсолютно разным философиям.
Spring Data JPA декларирует:
Весь мир — это ООП. Данные — это объекты, а база данных — это просто хранилище для этих объектов, про реализацию которого разработчику знать не нужно. Занимайтесь своими объектами и не лезьте в базу данных.
Spring JDBC отвечает:
Данные — это таблицы, а объекты — это просто представления этих таблиц. Приложение — это всего лишь логический придаток, помогающий работать с базой данных. Работайте с базой данных прямо из приложения на понятном базе данных языке.
Таким образом, граница между Spring Data JPA и Spring JDBC проходит в первую очередь в области восприятия данных (или в философской, как кому нравится).
Spring Data JDBC не так радикален по отношению к Spring Data JPA. Его CrudRepository является упрощением JpaRepository. Он поддерживает некоторые ORM-аннотации: Entity, Id и прочие. По сути, Spring Data JDBC является менее абстрактной и более прозрачной реализацией Spring Data JPA. Он не представляет из себя нечто самобытное, поэтому не так интересен для разбора.
Какие альтернативы?
В ходе анализа решений Spring стало очевидно, что оба фреймворка используют радикально разные подходы в работе с данными. Казалось бы, контроль над запросами очень важен для приложений, особенно высоконагруженных. Но Spring Data JPA такого контроля не даёт. Лёгкость и простота изменения кода является залогом его чистоты и работоспособности, однако с этим есть сложности уже у Spring JDBC.
Всего-то нужен фреймворк, предоставляющий полный контроль над запросами со стороны разработчика и не создающий трудностей при развитии, изменении и рефакторинге кода. Мы разберём два DSL-фреймворка, которые, на мой взгляд, в меньшей степени подвержены проблемам Spring Data JPA и Spring JDBC. Один на Java, другой на Kotlin.

И о них мы поговорим в продолжении, которое выйдет в следующую среду.
Если некогда читать или не хочется ждать...
...есть запись доклада по теме, который прошёл на конференции Spring NOW 2025 6 марта 2025 года. Доклад полностью раскрывает тему — пусть и в немного сокращённом виде, зато с live-кодингом.
Запись доклада также есть на других площадках.
На YouTube.
На RuTube.