JavaRush /Курси /Hibernate deep-dive /Persistence-аудит: перший рефакторинг

Persistence-аудит: перший рефакторинг

Hibernate deep-dive
Рівень 29 , Лекція 4
Відкрита

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 тестом, то аудит перетворюється на декоративну процедуру.

1
Опитування
ORM Антипатерни, рівень 29, лекція 4
Недоступний
ORM Антипатерни
Помилки persistence і транзакцій
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ