Каждое Java-приложение, которое работает с базой данных, решает одну задачу: как переложить данные из строк и столбцов таблиц в объекты Java — и обратно. Разберём, почему для этого придумали ORM, кто такие JPA и Hibernate и когда этот инструмент стоит применять.
Один вызов сверху вниз и обратно. Вы объявили интерфейс репозитория — реализацию написал Spring Data. Он зовёт EntityManager, а это только стандарт, набор интерфейсов; всю работу делает Hibernate: по маппингу класса Order собирает SELECT, отдаёт его JDBC и раскладывает полученную строку по полям объекта. ResultSet в вашем коде так и не появляется.
Что болит без ORM: жизнь с чистым JDBC
Представим задачу: загрузить заказ из базы вместе с его строками.
String sql = "SELECT o.id, o.status, ol.product_id, ol.quantity " +
"FROM orders o JOIN order_lines ol ON ol.order_id = o.id " +
"WHERE o.id = ?";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, orderId);
ResultSet rs = ps.executeQuery();
Order order = null;
List<OrderLine> lines = new ArrayList<>();
while (rs.next()) {
if (order == null) {
order = new Order(rs.getLong("id"), rs.getString("status"));
}
lines.add(new OrderLine(rs.getLong("product_id"), rs.getInt("quantity")));
}
if (order != null) order.setLines(lines);
}
Код рабочий, но в нём почти ничего про бизнес-логику — только про перекладывание данных. Добавьте обработку null, несколько таблиц, обновление — и объём вырастет в разы. Это шаблонный код (boilerplate): повторяющийся, механический и склонный к ошибкам при изменении схемы.
Объектно-реляционное отображение (ORM) — это подход, при котором фреймворк берёт перекладывание данных на себя. Вы описываете, как Java-класс соответствует таблице, а фреймворк генерирует SQL, выполняет запросы и собирает объекты из результатов.
Кто есть кто: JPA, Hibernate и Spring Data JPA
В проекте подключён Spring Data JPA, в стеке падает Hibernate, а аннотации импортируются из jakarta.persistence: три названия на одну работу, и путают их постоянно. Это три слоя одного механизма.
JPA (Jakarta Persistence API) — это стандарт, спецификация. Набор интерфейсов и аннотаций (@Entity, @Id, @OneToMany, EntityManager), которые описывают, как должен работать ORM в Java. JPA сам по себе не выполняет никакой работы — это только контракт.
Пакет в Jakarta EE / Spring Boot 3: jakarta.persistence (до Spring Boot 3 был javax.persistence).
Hibernate — это реализация JPA. Он содержит весь реальный код: парсер аннотаций, генератор SQL, управление соединениями, кэши. Когда приложение на Spring Boot делает запрос через JPA, физически работает Hibernate. Hibernate также предлагает свои расширения поверх стандарта (Session, HQL, специфичные аннотации).
Spring Data JPA — это слой репозиториев поверх JPA/Hibernate. Он убирает даже тот минимальный код, который остался бы при прямом использовании EntityManager: объявляете интерфейс — Spring генерирует реализацию.
| Слой, сверху вниз | Что он делает |
|---|---|
| ваш код | зовёт метод репозитория и получает объект |
| Spring Data JPA | репозитории, методы findBy* |
JPA / EntityManager | стандартный API: интерфейсы и аннотации |
| Hibernate | реализация: генерация SQL, кэши, сессия |
| JDBC | драйвер и соединение с базой |
| база данных | таблицы и строки |
Про версии стоит сказать один раз и точно, потому что примеры в интернете разбиты этой границей пополам. Spring Boot 3 принёс Hibernate 6 и переход с javax.persistence на jakarta.persistence — это часть общего переезда платформы Jakarta EE, а не прихоть Hibernate. Отсюда правило чтения чужих примеров: javax.persistence в импортах означает Spring Boot 2 и Hibernate 5, и вместе с пакетом там могут отличаться детали поведения. Просто заменить javax на jakarta в старом примере обычно достаточно, чтобы он скомпилировался, но проверять стоит и остальное.
И оговорка, которая экономит месяцы: ORM не отменяет знания SQL. Hibernate сам сочиняет запросы, но отвечает за них ваша база — и когда отчёт тормозит, читать придётся план запроса, а не аннотации. Как это делать, разобрано в статье про EXPLAIN; включить показ самих запросов — первое, что настраивают в новом проекте.
Что такое Entity и как это выглядит
Сущность (Entity) — Java-класс, который Hibernate отображает на таблицу базы данных. Минимальный пример:
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
@Column(name = "status")
private OrderStatus status;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderLine> lines = new ArrayList<>();
// конструкторы, геттеры, сеттеры
}
Hibernate читает аннотации и знает: Order → таблица orders, поле id → столбец id с автоинкрементом, коллекция lines → связанная таблица через @OneToMany.
Теперь загрузка заказа:
Order order = entityManager.find(Order.class, orderId);
Hibernate сам генерирует SQL, выполняет запрос и собирает объект. Маппинг ResultSet исчез из вашего кода.
Цена в памяти
У удобства есть измеримая цена, и её полезно представлять числами. На каждую загруженную строку в контексте появляется не один объект, а два: сама сущность и её снимок — копия значений на момент загрузки, по которой Hibernate потом ищет изменения. Плюс записи во внутренних картах контекста, плюс обёртки коллекций у связей.
Практическое следствие: выборка в сто тысяч строк через сущности стоит в разы больше памяти, чем те же данные, прочитанные как плоский результат запроса. Там, где сотня мегабайт данных превращается в гигабайт объектов, приложение падает не от базы, а от нехватки памяти — и это самый частый способ уронить сервис невинным findAll().
Отсюда правила, которые стоит держать в голове с первого дня. Сущности — для работы с одним объектом или десятками: загрузили заказ, изменили, сохранили. Для чтения списков берут проекции — записи с нужными полями вместо сущностей; для массовой обработки — порции с очисткой контекста между ними; для отчётов и выгрузок — вообще не ORM, а обычный запрос.
Тонкий контроль над SQL: где ORM уступает
Аргумент «нужен точный SQL» звучит абстрактно, пока не увидишь разницу. Отчёт «выручка по продавцам с накопительным итогом и долей в общем объёме» на оконных функциях — это один запрос, который в сущностях не выражается вовсе: у него нет строк, соответствующих объектам предметной области.
живой пример
SELECT seller_id,
sum(total_amount) AS revenue,
sum(sum(total_amount)) OVER (ORDER BY sum(total_amount) DESC) AS running,
round(100.0 * sum(total_amount) / sum(sum(total_amount)) OVER (), 1) AS share
FROM orders
WHERE paid_at IS NOT NULL
GROUP BY seller_id
ORDER BY revenue DESC;
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Такие запросы пишут напрямую, и вопрос только в том, чем. В этом курсе слой доступа к данным строится на jOOQ: запрос остаётся SQL, но проверяется компилятором и генерируется из схемы, поэтому опечатка в имени колонки ловится при сборке, а не в проде. Для разовых случаев подходит и JdbcClient из Spring. Граница между слоями простая: изменения состояния — через сущности, чтение отчётов и списков — напрямую; как это уживается в одном приложении, разобрано в конце статьи.
Ссылка вместо объекта: getReference
Частая операция: создаём заказ и надо проставить покупателя, чей идентификатор уже известен. Загружать покупателя целиком незачем — в базу уйдёт только его идентификатор в колонке customer_id:
Order order = new Order();
order.setCustomer(em.getReference(Customer.class, customerId));
em.persist(order);
getReference возвращает ссылку-заместитель: объект нужного типа, внутри которого пока нет ничего, кроме идентификатора. Запрос в базу не идёт до первого обращения к любому другому полю. В Spring Data тот же смысл у getReferenceById.
Две оговорки. Если такого покупателя в базе нет, ошибка придёт не сразу, а при первом обращении к полям (или при вставке — от внешнего ключа), и выглядеть будет непонятно. И за пределами транзакции у ссылки нельзя прочитать ничего, кроме идентификатора, — это тот же заместитель, о котором речь в статье про ленивую загрузку.
Когда ORM помогает
ORM хорошо работает там, где логика приложения вращается вокруг объектов предметной области: создание, изменение, связи между сущностями, проверки. Типичные случаи:
- CRUD-операции над отдельными сущностями: создать заказ, изменить адрес, добавить строку.
- Навигация по графу объектов:
order.getCustomer().getAddress()— Hibernate подгрузит данные сам. Работает это, пока вы внутри транзакции и не зовёте такую цепочку в цикле по списку: снаружи транзакции связь уже не подгрузится, а в цикле каждый шаг превратится в отдельный запрос. Обе ловушки разбираем дальше в Lazy vs Eager и N+1. - Управление транзакцией и dirty checking: Hibernate отслеживает, что изменилось в сущности, и сам пишет
UPDATEв конце транзакции. Вам не нужно вызыватьsave()вручную. - Кэширование: первый уровень кэша (в рамках одной сессии) встроен по умолчанию; второй уровень — по настройке.
Когда ORM мешает
ORM — не универсальный инструмент. Есть ситуации, где он добавляет сложность, а не убирает её.
Массовые операции. Обновить статус у миллиона строк через entityManager.find() в цикле — катастрофа по производительности. Hibernate будет загружать каждый объект в память, отслеживать изменения, генерировать отдельный UPDATE. Правильный путь — JPQL bulk-запрос или нативный SQL:
// JPQL bulk update — один SQL-запрос
entityManager.createQuery(
"UPDATE Order o SET o.status = :newStatus WHERE o.status = :oldStatus"
).setParameter("newStatus", OrderStatus.ARCHIVED)
.setParameter("oldStatus", OrderStatus.SHIPPED)
.executeUpdate();
Такой запрос идёт мимо контекста персистентности: объекты, уже загруженные в память, о нём не узнают и останутся со старым статусом. Лечится это очисткой контекста сразу после запроса — entityManager.clear(), а в Spring Data тот же смысл у флага @Modifying(clearAutomatically = true). Подробнее — в статье Типичные ошибки.
Сложные аналитические запросы и отчёты. Когда запрос объединяет 10 таблиц, использует оконные функции, GROUPING SETS, сложную агрегацию — ORM становится помехой. Здесь лучше нативный SQL или специализированный инструмент (jOOQ, например).
Тонкий контроль над SQL. Вы не всегда знаете, какой запрос сгенерирует Hibernate, — а там, где каждый запрос оптимизируется вручную, это мешает.
Persistence Context: главная концепция Hibernate
Изменили поле у загруженной сущности, save не вызывали, а в базу ушёл UPDATE; загрузили одну строку дважды и получили один и тот же объект. Оба сюрприза объясняет persistence context (контекст персистентности) — рабочая область сессии: карта всех объектов, которые Hibernate загрузил или создал в текущей транзакции.
При entityManager.find(Order.class, 1L) Hibernate сначала смотрит в эту карту: если объект с таким id там уже есть — возвращает его из памяти без запроса, если нет — выполняет SELECT и кладёт результат туда же.
В конце транзакции Hibernate проходит по всем объектам в persistence context, сравнивает с исходным состоянием и генерирует UPDATE там, где что-то изменилось. Это называется dirty checking.
@Transactional
public void updateStatus(Long orderId, OrderStatus newStatus) {
Order order = entityManager.find(Order.class, orderId);
order.setStatus(newStatus); // просто меняем поле
// Hibernate сам сгенерирует UPDATE при commit — явный save() не нужен
}
Что ORM делает за вас: плоские строки в граф объектов
База отвечает прямоугольником. У заказа с двумя позициями результат JOIN — две записи, и заголовок заказа в них повторяется. Собрать из этого один объект с коллекцией — та самая ручная работа, с которой статья началась, и вот она целиком, без базы:
живой пример
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class RowsToGraphDemo {
public static void main(String[] args) {
List<Object[]> rows = List.of(
new Object[]{1L, "PAID", 10L, 2},
new Object[]{1L, "PAID", 11L, 1},
new Object[]{2L, "NEW", 10L, 5});
Map<Long, String> orders = new LinkedHashMap<>();
Map<Long, List<String>> lines = new LinkedHashMap<>();
for (Object[] row : rows) {
Long orderId = (Long) row[0];
orders.putIfAbsent(orderId, (String) row[1]);
lines.computeIfAbsent(orderId, id -> new ArrayList<>())
.add("товар " + row[2] + " x" + row[3]);
}
orders.forEach((id, status) ->
System.out.println("Order(" + id + ", " + status + ") " + lines.get(id)));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Три плоские строки стали двумя объектами с коллекциями. Ровно это Hibernate и берёт на себя: вы описали @OneToMany, а сборку графа он делает сам — и тем аккуратнее, чем больше в запросе таблиц.
Глубже: рядом с jOOQ и JdbcClient: граница и один контекстрасширенное
«Когда ORM мешает» выше не значит «выбросить Hibernate». В одном сервисе обычно живут оба инструмента: Hibernate для агрегатов, которые загружают, меняют и сохраняют целиком, и jOOQ или JdbcClient для отчётов, массовых обновлений и запросов, которые в JPQL не выражаются. Граница проходит по операции, а не по таблице: одна операция использует один инструмент.
Транзакция при этом общая. Spring даёт обоим одно соединение, если они настроены на один DataSource и работают внутри одного @Transactional: Hibernate берёт соединение через тот же механизм, что JdbcTemplate, и COMMIT один на всех. Две ловушки на стыке. Первая: Hibernate откладывает запись до flush, а jOOQ читает базу напрямую, поэтому jOOQ-запрос в той же транзакции не увидит сущность, которую только что сохранили; перед ним вызывают entityManager.flush(). Вторая, обратная: после UPDATE через jOOQ в контексте персистентности остаются сущности со старыми значениями, и следующий find вернёт их из памяти, а не из базы; после массовой правки контекст чистят clear() или загружают заново через refresh().
Правило выбора на практике: если код читает сущность, меняет поля и ждёт, что изменения сохранятся сами, это Hibernate; если код описывает запрос с группировкой, окнами и соединениями трёх таблиц или меняет тысячи строк одной командой, это jOOQ. Тащить отчёт через сущности так же плохо, как собирать агрегат из результата SELECT руками.
Коротко
- JDBC требует ручного маппинга
ResultSet→ объект — много шаблонного кода. - ORM берёт этот маппинг на себя: вы описываете соответствие класс/таблица, он генерирует SQL.
- JPA — стандарт (интерфейсы и аннотации). Hibernate — его реализация. Spring Data JPA — репозитории поверх.
- Аннотации JPA в Spring Boot 3 — из пакета
jakarta.persistence. - ORM хорошо подходит для CRUD и объектных операций; для массовых обновлений и сложной аналитики лучше нативный SQL, и такой запрос проходит мимо контекста персистентности.
- Ключевая концепция — persistence context: карта загруженных объектов плюс их снимок, по которому dirty checking решает, кому нужен
UPDATEпри commit. - Hibernate для агрегатов, jOOQ или
JdbcClientдля отчётов и массовых команд, транзакция общая; перед чужим чтениемflush(), после чужой записиclear()илиrefresh(). - На каждую строку в контексте живёт сущность плюс снимок для сравнения:
findAll()на сотне тысяч строк роняет приложение по памяти, а не по базе. - Связь проставляют ссылкой
getReference, не загружая объект; отчёты и списки пишут запросом (jOOQ,JdbcClient), а не сущностями. - Spring Boot 3 — это Hibernate 6 и
jakarta.persistence;javax.persistenceв примере означает Spring Boot 2.
Что почитать дальше
- Маппинг сущностей — аннотации
@Column,@Embedded, конвертеры типов, наследование. - Persistence Context — состояния сущности, dirty checking, flush и detach.
- Типичные ошибки с Hibernate — LazyInitializationException, N+1 и другие.
- Spring Data JPA — репозитории, derived queries,
@Queryповерх Hibernate.