1. Мета persistence-аудиту
Майже завжди проблеми з Hibernate починаються в момент, коли «ніби все працювало», але раптом стало повільно, почало генерувати дивний SQL, або команда боїться чіпати шар даних. Persistence-аудит — це спосіб подивитися на код заздалегідь і оцінити не «працює/не працює», а наскільки передбачувано і наскільки керовано він працює. Це не про красу і не про релігію анотацій, а про інженерну спостережуваність.
Ми вже бачили типові способи втратити контроль: entity leakage і giant graphs розмивають форму даних, overscoped tx і chatty repository — межу unit of work, EAGER і OSIV — місце, де взагалі виконується SQL. Тепер потрібен робочий маршрут: як пройти по одному use case так, щоб із цих запахів вийшов не список скарг, а перший зрозумілий крок рефакторингу.
Починати краще з одного use case. Якщо цей прохід виходить чесним, тими самими питаннями потім зручно дивитися вже на весь persistence layer.
Якщо спростити, то аудит перевіряє три речі: яку форму даних повертає use case, де проходить межа транзакції і який реальний SQL вийде з цього коду. Важливо, що ми не починаємо з «поміняємо fetch на EAGER» або «додамо @BatchSize». Ми починаємо з питань рівня «а навіщо ми взагалі повертаємо entity назовні?» і «чому цей метод тримає транзакцію, поки будує текстовий звіт?».
Міні-схема: як мислити аудитом
flowchart TD
A["Беремо один use case"] --> B["Визначаємо: читання чи запис?"]
B --> C["Дивимося форму даних: entity чи read-model?"]
C --> D["Дивимося межу транзакції: де @Transactional?"]
D --> E["Дивимося репозиторії: чи є цикли й зайва балаканина?"]
E --> F["Дивимося fetch-поведінку і межу запиту: де lazy/eager/OSIV змушують SQL виконуватися поза use case?"]
F --> G["Обираємо 1 перший крок рефакторингу"]
G --> H["Перевіряємо SQL/Statistics/ORM-regression тестом"]
Зверніть увагу: мета не «одразу ідеально», а один перший крок, який максимально підвищує передбачуваність. Це як прибирання квартири: якщо відразу намагатися помити все, ви сядете на підлозі серед ганчірок і дивитиметеся в порожнечу. Один перший крок — це «спочатку прибираємо стіл, щоб було де готувати далі».
2. Use case і потрібні дані
Persistence-аудит приємно починати з най«нуднішого» — з читання сервісного методу очима людини, яка не знає ваш проєкт. Саме тут зазвичай зʼясовується, що метод називається loadOrders, але всередині і читання, і запис, і форматування звіту, і раптом виклик saveAndFlush() «щоб уже точно». Use case — це як мінісценарій: якщо він сам по собі розмитий, Hibernate винним не буде, навіть якщо дуже постарається.
З практичної точки зору корисно спочатку відповісти на два запитання. По-перше, це read-case чи write-case. По-друге, що є «правильним результатом» — entity-граф чи спеціально сформована read-model. Щойно ви це проговорюєте, багато що стає підозрілим само собою: наприклад, «read-case для списку замовлень» майже ніколи не зобовʼязаний повертати List<PurchaseOrder>.
Швидкий приклад: entity витікає з read-сервісу
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
class OrderReadService {
private final OrderRepository orderRepository;
OrderReadService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Transactional(readOnly = true)
public PurchaseOrder getOrder(Long id) {
// У read-case назовні виходить entity: далі будь-хто може тригерити lazy-завантаження
// (у серіалізації, у логері, у мапері тощо), і форма читання стане неявною.
return orderRepository.findById(id).orElseThrow();
}
}
Код «правильний» у тому сенсі, що він компілюється і навіть працює. Але аудит ставить наступне запитання: хто тепер контролює, що саме буде завантажено з бази? Відповідь неприємна: той, хто отримав entity і почав по ній ходити, можливо взагалі поза транзакцією, можливо в логері, можливо в серіалізації. Тобто форма читання стала неявною.
У цій точці зазвичай вистачає одного жорсткого запитання. Якщо це read-case, навіщо назовні взагалі виходить entity, а не вузька read-model на кшталт OrderSummary(...)? Якщо це write-case, то де тоді справжній unit of work і чому метод виглядає як читання? Одне це запитання швидко відділяє «нам потрібен DTO/projection/query» від «нам потрібен конкретний aggregate для зміни».
Projection і конструктор DTO теж не магія. Щойно ви тягнете вкладені властивості глибоко, ви вже обираєте join, ширину SELECT і ціну читання. Але це хоча б усвідомлене рішення на рівні use case, а не побічний ефект того, хто пізніше смикне геттер.
3. Red flags: швидкий скан
Коли ви вже зрозуміли use case, хочеться швидко пробігтися по коду і знайти «запахи», які майже завжди призводять до хаосу в persistence-шарі. Хороша новина: у persistence anti-patterns є доволі повторюваний вигляд. Погана новина: IDE їх не підсвітить червоним. Зате їх можна ловити звичкою ставити одні й ті самі запитання до кожного методу.
Щоб не перетворювати аудит на «смаківщину», зручно мислити так: у кожного red flag має бути перший невеликий крок, який покращує передбачуваність. Не «переписати все», а один конкретний крок: повернути DTO, прибрати flush, схлопнути цикл, зробити явний query, звузити транзакцію. Частина цих прапорців про зайві запити, частина — про розмиту write-модель, і корисно тримати обидві групи перед очима.
| Red flag у коді | Чому це небезпечно для ORM-поведінки | Перший розумний крок (не революція) |
|---|---|---|
| Сервіс повертає entity із read-case | Форма даних і точки завантаження стають неявними, зростає шанс випадкового lazy-дочитування і N+1 | Зробити read-model (DTO/projection) під сценарій |
| @Transactional тримає всередині «постобробку»: форматування, збір рядків, складні обчислення | Транзакція стає overscoped, усередині неї можуть ставатися зайві flush і lazy-load | Винести post-processing із транзакції, залишити всередині тільки unit of work |
| findById() у циклі за списком id | Це прямий шлях до chatty repository і «ручного N+1», навіть якщо fetch-plan нормальний | Схлопнути в один запит (findAllById, IN, query repo) |
| saveAndFlush() «про всяк випадок» після зміни managed entity | Часто це зайвий flush і зайва плутанина (аж до зайвих SELECT із merge-semantics) | Прибрати saveAndFlush, залишити dirty checking (або робити flush усвідомлено) |
| EAGER ставлять «щоб не думати» | EAGER не гарантує оптимальний SQL, роздуває граф, створює secondary selects | Повернути LAZY і зробити fetch-plan під use case (explicit query / graph / join fetch) |
| SQL іде в контролер, серіалізацію або логування; проєкт живе на open-in-view=true | Request-boundary session маскує відсутність fetch-design, і use case перестає контролювати момент запитів | Повернути завантаження всередину use case, тримати open-in-view=false, зафіксувати read-model / fetch-plan |
| Один root тягне не повʼязані колекції та lifecycle-правила в більшості use cases | Giant graph виявляється не лише fetch-проблемою, а надто широкою write-моделлю | Переглянути aggregate boundary: що реально має змінюватися атомарно разом |
| Репозиторій сприймають як такий, що «сам обере правильну ціну» | Repository API не проєктує use case і не фіксує форму читання | Дати use-case-oriented методи: findForEditing, findSummaries... |
Ця табличка не «істина на віки», а простий спосіб дисциплінувати мислення. Якщо ви бачите один із red flags, ви не зобовʼязані лагодити все відразу. Але ви зобовʼязані чесно сказати: «так, тут ціна і поведінка неявні».
4. Findings і перший крок
Сильна сторона аудиту — не в тому, що ви вмієте критикувати. Сильна сторона — у тому, що ви вмієте фіксувати спостереження і привʼязувати його до конкретного першого кроку. Дуже корисно мислити такими «знахідками» (findings), щоб обговорення на code review було предметним і коротким. Інакше розмова перетворюється на вічне: «ну тут щось не так із Hibernate».
Почати можна з простого формату: у нас є опис проблеми і перший крок. Це дисциплінує навіть одного розробника, а в команді ще й знижує емоційність обговорень (ніхто не любить, коли його код називають «жахливим», але майже всі нормально ставляться до фрази «ось симптом, ось перший крок»).
Мініформат Finding у коді та нотатках ревʼю
// «Finding» — це не баг-репорт, а коротка звʼязка: симптом → перший невеликий крок.
record Finding(String problem, String firstMove) {}
І приклад набору findings:
import java.util.List;
class AuditNotes {
// Важливо: у кожного симптому є «перша допомога», яку реально зробити швидко.
List<Finding> findings = List.of(
new Finding("entity leakage", "повертати OrderSummary замість PurchaseOrder"),
new Finding("SQL у web/request scope", "повернути завантаження всередину use case і тримати open-in-view=false"),
new Finding("repository in loop", "замінити пошук за id у циклі на один запит"),
new Finding("занадто широка aggregate boundary", "відокремити форму читання від даних, які реально змінюються в одному unit of work"),
new Finding("unnecessary saveAndFlush", "залишити оновлення dirty checking")
);
}
Тут важлива не Java-красивість, а думка: кожен симптом має мати «першу допомогу». Причому перша допомога має бути такою, щоб її реально зробити без тижня міграцій і зламаної половини тестів.
Якщо SQL уже поїхав у controller/JSON або root тягне не повʼязані колекції заради половини сценаріїв, такий finding часто важливіший за косметичну оптимізацію одного запиту. Тут ламаються самі межі use case.
Пріоритет: який finding лагодити першим
У межах сьогоднішньої теми корисно тримати простий принцип пріоритету. Спочатку лагодиться те, що найсильніше підвищує передбачуваність форми даних і меж use case. На практиці це часто означає: спочатку прибираємо entity leakage (або хоча б обмежуємо його), потім схлопуємо chatty repository, потім уже чіпаємо flush-звички та fetch-стратегії. Але це не жорстке правило, а спосіб не втонути в нескінченних покращеннях.
5. Перевірка ефекту: SQL і тест
Погана звичка в рефакторингу persistence-коду — «ну ніби стало красивіше». Краса хороша, але Hibernate вміє бути красивим, а SQL при цьому — дуже балакучим. Тому аудит і рефакторинг мають завершуватися спостережуваною перевіркою: або ви дивитеся SQL trace, або, ще краще, фіксуєте поведінку тестом. Ідеальний варіант — обидва. Але навіть мінімальна перевірка вже дисциплінує: після правки потрібно побачити, чи не поїхав SQL у request scope, чи не розбалансувався query count і чи не зʼявився зайвий UPDATE.
Суть ORM-regression тесту проста: він перевіряє не лише «дані повернулися», а й те, що ORM не зробив чогось несподіваного, наприклад не виконав 51 запит замість 2. З таким тестом сперечатися складно: або запитів стало менше, або ні. Hibernate може сперечатися, але зазвичай мовчки. Особливо корисно так ловити випадки, де happy-path виглядає невинно, а SQL їде в серіалізацію, логування або інший зовнішній код.
Приклад: вмикаємо Hibernate statistics у @DataJpaTest
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.TestPropertySource;
@DataJpaTest
@TestPropertySource(properties = {
// Увімкнемо збір статистики Hibernate, щоб тест міг виміряти кількість запитів.
"spring.jpa.properties.hibernate.generate_statistics=true"
})
class OrderQueryTest {
}
Це не єдиний спосіб, але він компактний і добре підходить для лабораторної кодової бази.
Приклад: знімаємо кількість підготовлених SQL-операторів
import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
class HibernateStats {
static long preparedStatements(EntityManagerFactory emf) {
// Дістаємо Hibernate SessionFactory з JPA, щоб отримати доступ до статистики.
SessionFactory sf = emf.unwrap(SessionFactory.class);
Statistics stats = sf.getStatistics();
// Метрика проста: скільки підготовлених SQL-операторів було від моменту старту.
return stats.getPrepareStatementCount();
}
}
Так, це не «ідеальна метрика світу», але вона допомагає зловити регресії у стилі «а чому запитів стало у 10 разів більше». І для навчального проєкту це цілком чесний інструмент.
Мінітест: перевіряємо, що read-case не розбалансувався
import jakarta.persistence.EntityManagerFactory;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.assertj.core.api.Assertions.assertThat;
class OrderQueryTest {
@Autowired EntityManagerFactory emf;
@Autowired OrderQueryRepository orderQueryRepository;
@Test
void findSummaryById_shouldBeCheap() {
// Важливо памʼятати: лічильник накопичувальний, тому тест порівнює "до/після" одного виклику.
long before = HibernateStats.preparedStatements(emf);
// Ми перевіряємо саме «ціну» читання, а не лише коректність даних.
orderQueryRepository.findSummaryById(1L);
long after = HibernateStats.preparedStatements(emf);
assertThat(after - before).isLessThanOrEqualTo(2);
}
}
В ідеальному світі ви б точніше контролювали оточення та очищення статистики. Але навіть така форма вже дисциплінує: якщо хтось «випадково» повернув entity і почав чіпати lazy-звʼязки, тест почне сигналізувати.
6. Кейс: із «комбайна» в read-case
Найкорисніший формат аудиту — взяти один проблемний метод і прогнати по ньому наш підхід: use case → red flags → findings → перший рефакторинг → перевірка. Давайте зробимо це на типовому «комбайні», який одночасно і читає, і пише, і формує результат. Такий метод часто пишуть не тому, що розробник поганий, а тому, що він поспішав, а Hibernate в цей момент… мовчазно записував усе в блокнот.
До аудиту: метод із набором red flags
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
@Service
class ProblematicOrderService {
private final OrderRepository orderRepository;
ProblematicOrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Transactional
public List<PurchaseOrder> loadOrders(List<Long> ids) {
List<PurchaseOrder> result = new ArrayList<>();
for (Long id : ids) {
// Тривожний сигнал: findById у циклі → chatty repository і майже гарантований "ручний N+1".
PurchaseOrder order = orderRepository.findById(id).orElseThrow();
// Тривожний сигнал: saveAndFlush без явної зміни → зайвий flush «про всяк випадок».
// Тривожний сигнал: назовні повертається entity → далі почнуться неявні lazy-дочитування.
result.add(orderRepository.saveAndFlush(order));
}
return result;
}
}
Якщо дивитися очима аудитора, тут майже комбо-набір. Повернення entity назовні натякає на entity leakage. Репозиторій у циклі — chatty repository. saveAndFlush() без явної зміни — це пряме прохання до Hibernate «зроби щось… будь ласка». І все це в межах однієї транзакції, хоча ми навіть не розуміємо, це read-case чи write-case.
Крок 1: сформулювати use case людською мовою
Це читання чи запис? Судячи з назви loadOrders і відсутності змін — це читання. Тоді @Transactional без readOnly = true уже підозрілий, а saveAndFlush() взагалі виглядає як кнопка «бо так спокійніше». Отже, наш перший крок рефакторингу має зробити читання явним і зібрати його в один запит.
Крок 2: перший хід — повернути read-model і схлопнути chatter
Зробимо read-model:
package com.example.commerce.orders.dto;
// DTO для конкретного сценарію: навмисно мало полів, щоб читання було передбачуваним.
public record OrderSummary(Long id, String orderNumber) {}
І query-репозиторій:
import com.example.commerce.orders.dto.OrderSummary;
import com.example.commerce.orders.entity.PurchaseOrder;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param;
import java.util.List;
interface OrderQueryRepository extends Repository<PurchaseOrder, Long> {
@Query("""
select new com.example.commerce.orders.dto.OrderSummary(o.id, o.orderNumber)
from PurchaseOrder o
where o.id in :ids
""")
// Один запит замість циклу за id: менше балаканини з БД, простіше контролювати SQL.
List<OrderSummary> findSummariesByIds(@Param("ids") List<Long> ids);
}
Тепер сервіс стає коротким і чесним:
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
class OrderAuditReadService {
private final OrderQueryRepository orderQueryRepository;
OrderAuditReadService(OrderQueryRepository orderQueryRepository) {
this.orderQueryRepository = orderQueryRepository;
}
@Transactional(readOnly = true)
public List<OrderSummary> loadOrderSummaries(List<Long> ids) {
// Read-case: повертаємо read-model і не тримаємо транзакцію довше, ніж потрібно для читання.
return orderQueryRepository.findSummariesByIds(ids);
}
}
Що ми виграли? Ми зробили форму даних явною, прибрали saveAndFlush(), прибрали findById() у циклі і уточнили транзакцію як read-only. Це і є «перший хід»: не перепис моделі, а виведення use case у зрозумілу форму.
Якщо аудит покаже, що метод узагалі не read-case, а write-case, хід інший: завантажується конкретний агрегат під зміну, явно фіксується потрібний fetch-plan, а сама зміна залишається всередині вузького unit of work. Тут уже не summary-DTO, а керований aggregate. Важливо саме не змішувати ці два режими в одному «універсальному» методі.
7. Типові помилки під час persistence-аудиту
Persistence-аудит легко зробити корисним інструментом команди, а легко — перетворити на нескінченне занудство, від якого всі ховатимуться в сусідній пакет. Помилки тут зазвичай не технічні, а методичні: як саме ви дивитеся на код і які висновки робите. Нижче — найчастіші граблі, на які наступають навіть хороші розробники, якщо в них немає звички доводити audit до конкретного результату.
Помилка №1: починати аудит з анотацій, а не з use case.
Дуже хочеться відкрити entity, побачити @ManyToOne(fetch = EAGER) і почати правити. Але якщо ви не розумієте, це list-case, detail-case чи write-case, ви лікуєте симптоми. Правильний ритм інший: спочатку прочитати сервісний метод як сценарій, зрозуміти вхід і вихід, і лише потім дивитися, які механіки Hibernate він запускає.
Помилка №2: вважати, що «запитів мало» означає «все добре».
Іноді запитів справді мало, але один запит тягне гігантський join і матеріалізує половину бази, тому що граф величезний. І навпаки: іноді кілька запитів із batch fetching — нормальний компроміс. Аудит не повинен упиратися лише в цифру, він має дивитися на передбачуваність форми даних і відповідність use case.
Помилка №3: робити “гігантський рефакторинг” замість першого кроку.
Якщо ви знайшли entity leakage, chatty repository і overscoped транзакцію, то спокуса — переписати весь модуль. Зазвичай це закінчується тим, що ви переписали половину, втомилися, і тепер стало ще страшніше. У здоровому audit-підході ви обираєте один перший крок, який можна завершити сьогодні: повернути OrderSummary замість entity, схлопнути цикл в один запит, прибрати saveAndFlush().
Помилка №4: змішувати read і write в одному “універсальному” методі.
Це класичне джерело хаосу: метод називається loadOrders, але всередині ще й оновлює статус, і пише аудит, і формує звіт. У результаті ви не можете поставити розумний @Transactional(readOnly = true), ви не можете обрати fetch-plan під сценарій, і кожен наступний розробник боїться чіпати код. Аудит має мʼяко, але наполегливо розділяти: read-case окремо, write-case окремо.
Помилка №5: не перевіряти рефакторинг по SQL/статистиці/тесту.
Найприкріша ситуація: ви «почистили код», а SQL став гіршим — просто тому, що змінився момент ініціалізації lazy-звʼязків або випадково зʼявилося більше читань. Якщо після зміни ви не подивилися SQL trace і не закріпили критичний сценарій ORM-regression тестом, то аудит перетворюється на декоративну процедуру.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ