JavaRush /Курсы /Hibernate deep-dive /ORM checklist: red flags в Hibernate

ORM checklist: red flags в Hibernate

Hibernate deep-dive
30 уровень , 2 лекция
Открыта

1. Checklist для persistence layer

Если в обычном Java-коде мы часто видим причину и следствие почти напрямую (вызвал метод — получил результат), то Hibernate любит сюрпризы: изменил поле — UPDATE появится позже, а не «вот сейчас»; взял order.getCustomer() — и внезапно уехал SQL; сделал bulk update — и persistence context тихо остался жить в своей параллельной реальности. Поэтому code review слоя данных — это не только «правильно ли написано», но и «что этот код на самом деле сделает в БД».

Матрица выбора полезна, пока вы смотрите на сценарий сверху. Но в PR никто не подписывает код словами «тут list-read через entity» или «тут я случайно создал stale state». В ревью вы сначала видите service, mapper, saveAndFlush(), обход графа и только потом соотносите это с матрицей. Checklist нужен именно для этого: быстро распознать red flag и понять, какой вопрос задать.

Checklist здесь работает как предполётный список пилота. Пилот тоже умеет летать без бумажки, но бумажка спасает от человеческого фактора: усталость, спешка, «да это же очевидно». В ORM всё усложняется тем, что часть поведения неявная: транзакции, flush, прокси, каскады, lazy loading, версии. Чеклист помогает превращать ревью из «мнений» в повторяемый процесс: мы задаём правильные вопросы, ищем red flags и, если нужно, превращаем их в finding с понятным риском.

2. Хороший ORM-checklist без запретов

Checklist для Hibernate-кода — не «чёрный список аннотаций» и не попытка запретить всё, кроме select * from .... Его задача проще и полезнее: перевести оси аудита в вопросы к конкретному PR. Каждый вопрос должен вести к инженерному решению: либо мы убеждаемся, что всё ок (и спокойно мёрджим), либо фиксируем риск и просим уточнить/переделать.

Полезно мыслить пунктом чеклиста как маленькой конструкцией «сигнал → вопрос → риск». Сигнал — это red flag, то есть узнаваемый паттерн в коде. Вопрос — то, что мы задаём автору (или себе), чтобы выяснить, есть ли причина так делать. Риск — то, что случится в рантайме, если причины нет. И уже после этого мы выбираем действие: оставить как есть, попросить правку, предложить альтернативу из матрицы решений.

Ниже — компактная «карта» блоков, из которых обычно состоит checklist для нашего Commerce Persistence Lab.

Блок чеклиста На что смотрим в коде Типичный риск, если это “просто так”
State transitions save(), saveAndFlush(), merge(), clear(), detach() лишние SQL, преждевременный flush, merge-ловушки, непредсказуемый граф
Transaction boundary где стоит @Transactional, где его нет, и где он слишком широкий LazyInitializationException, неконсистентные чтения, длинные транзакции, «плавающие» SQL
Fetch & read-model findAll()+stream(), обход графа, EAGER, toString() на entity N+1, overfetching, случайные запросы, тяжёлый dirty checking на read-path
Mapping quality helper-методы, mappedBy, cascade, orphanRemoval, equals/hashCode FK не обновляется как ожидаем, лишние delete/insert, поломанные коллекции, proxy-подлость
Concurrency @Version, lock modes, критические секции lost update, блокировки, deadlock-мышление «придёт потом (нет)»
Bulk & read-only @Modifying bulk, read-only hints/transactions stale persistence context, «мы обновили БД, но объект думает иначе», странные эффекты при чтении

Чтобы это не оставалось теорией, полезно держать в голове простой рабочий маршрут ревью:

flowchart TD
    A["Выбираем use case / hot path PR"] --> B["Понимаем: read или write?"]
    B --> C["Проходим блоки checklist"]
    C --> D["Фиксируем red flags как вопросы"]
    D --> E["Если риск реальный: предлагаем решение из матрицы"]

Заметьте: здесь нет шага «сразу спорим про аннотации». Мы сначала понимаем сценарий и только потом смотрим, насколько код под него подходит.

Но checklist сам по себе — это ещё не доказательство. Он полезен ровно до того места, где вы сказали: «похоже на N+1», «похоже на лишний flush», «похоже на stale state». Дальше это подозрение нужно подтверждать по SQL, счётчикам и тестам, иначе review останется на уровне интуиции.

3. State transitions: save, flush, merge

В этом блоке мы проверяем, что автор PR понимает, в каком состоянии живёт сущность и почему именно он вызывает те или иные методы. Hibernate не про «вызвал save() — сохранилось», а про unit of work: managed-сущность меняется в памяти, а SQL уезжает в БД в моменты flush/commit. Поэтому любое «лишнее» действие вокруг состояния — это красный флажок: оно почти всегда либо скрывает непонимание, либо чинит симптом вместо причины.

Red flag: saveAndFlush() внутри транзакции «на всякий случай».
Это один из самых частых сигналов. saveAndFlush() не просто «сохранить», он ещё и принуждает flush прямо сейчас. Если это не нужно для корректности (например, нам не надо, чтобы данные стали видны в БД до следующего запроса), то это лишний SQL и потенциальная непредсказуемость.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
class OrderAdminService {

    @Transactional
    public void changeStatus(PurchaseOrder order, OrderStatus status) {
        // Сущность managed внутри транзакции: Hibernate сам увидит изменения (dirty checking)
        order.setStatus(status);

        // Red flag: форсируем flush прямо сейчас.
        // Нужно только если дальше в этом же методе есть запрос/constraint,
        // которому важно видеть изменения до commit.
        // orderRepository.saveAndFlush(order);
    }
}

В ревью хороший вопрос звучит не как «убери это», а как «зачем тебе ранний flush именно здесь? есть ли запрос/constraint, который требует синхронизации до конца транзакции?». Если ответа нет — вероятнее всего, метод лишний.

Red flag: save() после findById() и изменения managed-сущности.
Чисто психологически хочется «закрепить сохранение». Но Hibernate уже «держит» сущность: dirty checking сделает свою работу. save() тут либо ничего не даёт, либо (в зависимости от newness/идентификатора) может косвенно увести нас в merge-семантику, что особенно неприятно на графах.

import org.springframework.transaction.annotation.Transactional;

@Transactional
public void renameProduct(long id, String newName) {
    // Загружаем entity: дальше она будет managed в рамках транзакции
    Product product = productRepository.findById(id).orElseThrow();

    // Достаточно изменить поле: UPDATE уйдёт на flush/commit автоматически
    product.setName(newName);

    // Red flag: чаще всего лишняя операция, создающая иллюзию «я сохранил руками».
    // productRepository.save(product);
}

Red flag: «DTO → entity → save» для update-path.
Если вы видите код, где создают новый объект Product, ставят ему id, копируют поля из DTO и делают save(), то это почти всегда приглашение на merge-вечеринку. А merge, как мы уже знаем, любит скрытые SELECT, копирование состояния и сюрпризы на графах.

В ревью здесь стоит спрашивать: «почему не find + mutate?». Обычно это и проще, и предсказуемее.

import org.springframework.transaction.annotation.Transactional;

@Transactional
public void updateProduct(long id, String name) {
    // Предсказуемый update-path: нашли managed entity
    Product product = productRepository.findById(id).orElseThrow();

    // И точечно поменяли нужные поля (без merge и его сюрпризов)
    product.setName(name);
}

Red flag: clear() / detach() без очень понятной причины.
Эти методы — не «оптимизация», а изменение правил игры: вы вручную выключаете наблюдение Hibernate за объектами. Это бывает нужно в bulk-сценариях или в специфических лабораториях, но в обычном CRUD-потоке без объяснения выглядит подозрительно.

Если вы видите entityManager.clear() посреди сервиса, задайте простой вопрос: «какой stale-state или memory-проблемой это продиктовано?». Если ответ звучит как «чтобы работало» — это почти гарантированно долг в архитектуре.

4. Транзакционная граница и Session

Дальше мы проверяем, что код вообще выполняется в корректной транзакционной рамке. В нашем курсе и в проекте baseline — open-in-view=false, а значит, надеяться на «ну в контроллере ещё была сессия» нельзя. Любой lazy доступ вне транзакции либо упадёт, либо заставит вас включить OSIV и спрятать проблему (а это мы не делаем).

Red flag: сервисный метод, который делает несколько операций записи без @Transactional.
Иногда автор «раскидал» логику по репозиториям и надеется, что каждый save() как-то сам по себе «атомарен». Но unit of work должен быть на уровне бизнес-операции.

import org.springframework.stereotype.Service;

@Service
class InventoryService {

    public void reserve(long itemId, int qty) {
        // Без @Transactional: объект может стать detached сразу после метода репозитория
        InventoryItem item = inventoryRepository.findById(itemId).orElseThrow();

        // Red flag: изменения могут не попасть в БД, потому что нет unit of work и flush/commit
        item.reserve(qty);
    }
}

На ревью здесь важно спрашивать: «какая граница unit of work? где @Transactional и какой режим (read-only или write)?». Если метод — write-path, транзакция должна быть явной.

Red flag: self-invocation и иллюзия транзакции.
Это тонкий баг, который выглядит как «всё правильно», но транзакция не открывается, потому что Spring не может применить прокси к вызову внутри того же объекта.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
class OrderService {

    public void cancel(long id) {
        // Red flag: вызов метода внутри того же класса обходит Spring-прокси,
        // из-за чего @Transactional может не сработать
        cancelTx(id);
    }

    @Transactional
    void cancelTx(long id) {
        // Транзакция гарантированно откроется только если метод будет вызван через прокси
        /* ... */
    }
}

Чеклистовый вопрос здесь простой: «точно ли метод вызывается через прокси Spring?». Часто решение — вынести транзакционный метод в отдельный bean или перестроить orchestration.

Red flag: слишком широкая транзакция на read-path.
Да, транзакция нужна и для чтения (ради консистентности и предсказуемой lazy-загрузки), но «держать транзакцию вокруг всего мира» тоже опасно. На ревью стоит обращать внимание на то, что внутри read-only сценария не происходит случайных изменений managed-сущностей. Если вы видите @Transactional(readOnly=true), а внутри кто-то делает setName(), это уже не просто «стиль», это риск неожиданного поведения.

5. Fetching и read-model: паттерны N+1

Этот блок часто вызывает у новичков удивление: «как я могу говорить про N+1, если я ещё не смотрел SQL?». Спойлер: мы не утверждаем, что N+1 точно есть, но мы умеем видеть паттерны, которые почти всегда к нему приводят. Это как увидеть в коде while(true) без break: теоретически может быть нормально, но лучше спросить.

Red flag: findAll() + stream() + навигация по связям.
Это классический «прятатель запросов»: в коде один метод, а в БД — целая серия SELECT.

import java.util.List;
import org.springframework.transaction.annotation.Transactional;

@Transactional(readOnly = true)
public List<String> findOrderEmails() {
    // Red flag: findAll грузит root-сущности, а дальше мы идём по связям
    return orderRepository.findAll().stream()
            // Потенциальный N+1: getCustomer() может быть LAZY и дёргать отдельный SELECT на каждую строку
            .map(o -> o.getCustomer().getEmail())
            .toList();
}

В ревью чеклистовый вопрос звучит так: «это list-use case? точно ли нам нужны entity? почему не projection или явный fetch-plan?». И обратите внимание: это вопрос про решение, а не «про вкусы».

Один из нормальных ответов на этот red flag — projection.
Например, вместо entity-loading можно сделать query, которая сразу вернёт нужные значения. Это будет проще и для Hibernate, и для вашего мозга.

import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

interface PurchaseOrderRepository extends JpaRepository<PurchaseOrder, Long> {

    // Projection: возвращаем сразу «плоские» данные без загрузки графа сущностей
    @Query("select o.customer.email from PurchaseOrder o")
    List<String> findAllCustomerEmails(); // Ожидаемо один запрос
}

Red flag: “универсальная entity-модель” для списков.
Если PR добавляет метод вида List<PurchaseOrder> findByStatus(...) и затем в сервисе/маппере начинают вытаскивать из заказа всё подряд (клиент, позиции, суммы, адреса), это почти всегда сигнал, что read-model и write-model смешали. Чеклистовый вопрос: «какая форма выдачи нужна? можно ли отделить list-row DTO от detail-view?».

Red flag: toString()/логирование, которое трогает lazy-связи.
Если вы видите toString(), который печатает items, addresses, categories — это ORM-мина. В лучшем случае будет шумный SQL, в худшем — исключение, если toString() зовут вне транзакции.

@Override
public String toString() {
    // Red flag: обращение к lazy-коллекции в toString() может:
    // - внезапно инициировать SQL-запросы (внутри логирования!)
    // - упасть с LazyInitializationException вне транзакции
    return "PurchaseOrder{id=" + id + ", items=" + items + "}";
}

Чеклистовый вопрос: «точно ли toString() безопасен? не трогает ли он lazy-коллекции?». В учебном проекте лучше делать toString() минимальным: только id и пара простых полей.

6. Mapping quality: связи, каскады, equals

Переходим к тому, что часто ломает рантайм, даже если запросы написаны идеально: маппинг. Это как фундамент дома: снаружи всё красиво, но если фундамент «на честном слове», дом начинает трескаться в самый неудобный момент. Hibernate добавляет к этому свою специфику: owning side управляет FK, cascade отражает lifecycle, equals/hashCode взаимодействует с proxy, а коллекции — это не «просто ArrayList», а persistent collections.

Red flag: двунаправленная связь не синхронизируется helper-методом.
Если в PR вы видите прямой доступ к коллекции order.getItems().add(item) — это повод насторожиться. Очень легко забыть выставить owning side, и тогда внешний ключ либо не обновится, либо обновится «не так».

import java.util.ArrayList;
import java.util.List;

class PurchaseOrder {
    private final List<OrderItem> items = new ArrayList<>();

    public void addItem(OrderItem item) {
        // Меняем коллекцию на стороне parent (inverse/не owning в типичном маппинге)
        items.add(item);

        // Важно: синхронизируем owning side, иначе FK может не обновиться как ожидаем
        item.setOrder(this);
    }
}

Чеклистовый вопрос: «через что управляется связь: через helper или через голую коллекцию?». В нашем проекте правильный ответ почти всегда: через helper.

Red flag: cascade “ALL” на shared-ассоциации.
Если PurchaseOrder содержит ссылку на Customer, то Customer — точно не child сущность заказа. Cascade на ManyToOne к Customer легко приводит к случайным последствиям: от неожиданных persist до “ой, кажется, мы удалили клиента вместе с заказом”.

import jakarta.persistence.CascadeType;
import jakarta.persistence.ManyToOne;

class PurchaseOrder {

    // Red flag: Customer — shared сущность. CascadeType.ALL может протащить:
    // - случайный persist/merge на клиента
    // - а в худших конфигурациях и remove (что вообще не то, чего мы хотим)
    @ManyToOne(cascade = CascadeType.ALL)
    private Customer customer;
}

В ревью спрашиваем: «какой lifecycle? должен ли child жить и умирать вместе с parent?». Это отличный вопрос, потому что заставляет говорить о доменной модели, а не о магии аннотаций.

Red flag: equals() через getClass() (proxy-подстава).
Hibernate может подсовывать proxy-класс, и сравнение getClass() ломает equality. Это не «теория», это реальная причина странных багов в Set, orphan removal и в сравнении сущностей внутри persistence context.

import org.hibernate.Hibernate;

@Override
public boolean equals(Object o) {
    if (this == o) return true;

    // Важно: сравнение через Hibernate.getClass учитывает proxy-классы
    if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;

    Product other = (Product) o;

    // Базовая стратегия: сравнение по id, но только если он уже назначен
    return id != null && id.equals(other.id);
}

Чеклистовый вопрос: «учитывает ли equality наличие proxy? не зависит ли она от mutable-полей?». И да, это тот самый момент, когда @Data от Lombok становится вашим врагом, а не другом.

7. Конкурентность: версии и блокировки

Конкурентность в persistence layer — это не «высшая математика», а нормальная реальность. У нас в проекте самый очевидный contested write-path — инвентарь: несколько операций могут резервировать один и тот же товар. Поэтому чеклист должен быстро отвечать на вопрос: защищены ли мы от lost update и не превращаем ли БД в поле боя блокировок.

Red flag: write-path по contested данным без @Version.
Если PR меняет availableQty/reservedQty, а в entity нет версии, вы почти гарантированно получите тихую потерю обновлений при параллельной работе.

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Version;

@Entity
class InventoryItem {
    @Id
    private Long id;

    // Optimistic locking: Hibernate добавит проверку версии при UPDATE
    @Version
    private long version;
}

Чеклистовый вопрос: «есть ли versioning там, где возможны параллельные обновления?».

Red flag: pessimistic lock без осознания цены и границы.
Pessimistic locking нужен, но это тяжёлый инструмент: он должен быть в короткой транзакции и с понятной политикой ожидания. Если PR добавляет PESSIMISTIC_WRITE «чтобы наверняка», спрашиваем: «почему optimistic недостаточен? насколько часты конфликты?».

import java.util.Optional;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;

interface InventoryItemRepository extends JpaRepository<InventoryItem, Long> {

    // Pessimistic lock: блокируем строку в БД (дорого, но иногда необходимо)
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    Optional<InventoryItem> findLockedById(Long id); // Red flag, если нет причины
}

Red flag: долгие действия внутри lock-секции.
Если вы видите, что после взятия lock внутри транзакции делается «что-то долгое» (сложные вычисления, ожидания, большие циклы), это повод остановиться. Даже если это учебный проект, привычка опасная.

import org.springframework.transaction.annotation.Transactional;

@Transactional
public void reserve(long itemId, int qty) {
    // Взяли блокировку: критическая секция началась
    InventoryItem item = inventoryRepository.findLockedById(itemId).orElseThrow();

    // Red flag: долгое действие под блокировкой повышает шанс contention/deadlock
    slowBusinessLogic();

    // Лучше держать под lock только минимально необходимую часть
    item.reserve(qty);
}

На ревью вопрос: «можно ли сократить критическую секцию?». Часто ответ — да.

8. Bulk-операции и read-only: stale state

Этот блок нужен, потому что bulk update/delete — это «осознанный выход за entity-модель», и у него есть побочный эффект: persistence context не синхронизируется автоматически. Даже если код выглядит как «мы всё обновили», managed-объекты могут оставаться старыми. И это не баг Hibernate — это цена bulk подхода.

Red flag: @Modifying bulk update и продолжение работы с ранее загруженными entity.
Если сервис сначала делает find(), потом bulk update, а затем читает поля из managed-объекта — это почти учебник по stale persistence context.

import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;

interface ProductRepository {
    // Bulk update: минуем persistence context и dirty checking
    @Modifying
    @Query("update Product p set p.status = :status where p.deleted = false")
    int changeStatusForCatalog(ProductStatus status); // После этого managed-сущности могут стать stale
}

Чеклистовый вопрос к месту использования bulk: «что происходит с persistence context после bulk? он очищается? мы перезагружаем данные?». Здесь важно, что мы спрашиваем не “как правильно”, а “как ты это контролируешь”.

Red flag: read-only транзакция, но внутри мутируют entity.
Да, это звучит как «ну кто так делает», но в реальной жизни это происходит чаще, чем хотелось бы. Человек поставил readOnly=true как “оптимизацию”, а потом добавил setX() и забыл.

import org.springframework.transaction.annotation.Transactional;

@Transactional(readOnly = true)
public void accidentalWrite(long productId) {
    // Read-only транзакция: предполагаем «только чтение»
    Product p = productRepository.findById(productId).orElseThrow();

    // Red flag: мутация внутри read-only сценария создаёт неожиданности
    p.setName("Oops");
}

В ревью вопрос: «это точно read-only use case?». Если это запись — надо честно сделать write transaction и не маскировать.

9. Конструктивные ORM-review комментарии

Самый сильный чеклист может превратиться в бесполезный инструмент, если использовать его как дубинку. Особенно в Hibernate-теме, где у каждого есть травмы: кто-то однажды увидел LazyInitializationException и теперь боится слова LAZY, кто-то пережил N+1 и теперь JOIN FETCH ставит даже на чтение справочника «городов Месопотамии » (включая города, которых ещё не существует). Поэтому важна форма.

Хороший ORM-review комментарий обычно выглядит как вопрос + причина + предложение. Вопрос делает обсуждение уважительным и проверяет контекст (вдруг saveAndFlush() реально нужен). Причина объясняет риск (flush timing, stale state, N+1). Предложение даёт выход (projection, EntityGraph, find + mutate, @Version, clear после bulk). И отдельно полезно намекнуть, что решение должно быть проверяемым, но без ухода в детали: «давай убедимся по поведению», а не «я так сказал».

Вот маленькая табличка, как это может звучать:

Как писать не стоит Как лучше
«Зачем тут saveAndFlush()? Убери.» «Вижу saveAndFlush(). Нам нужен flush до конца метода? Если нет, можно убрать и положиться на dirty checking на commit — будет меньше неожиданного SQL.»
«Опять findAll()…» «findAll() + навигация по связям выглядит как кандидат на N+1. Это list-use case? Может, лучше projection/явный fetch-plan под сценарий?»
«CascadeType.ALL — зло.» «Здесь CascadeType.ALL на ManyToOne. Customer у нас shared entity, каскад может дать неожиданные side effects. Какой lifecycle ты хотел выразить?»

10. Типичные ошибки при использовании ORM-checklist

Ошибка №1: превращать чеклист в религию вместо инструмента.
Когда разработчик видит saveAndFlush() и автоматически пишет «запрещено», он перестаёт думать о причинах. А иногда причина есть: например, нужно, чтобы ограничения БД сработали до следующего запроса, или нужно получить id при определённой стратегии. Чеклист должен заставлять задавать вопросы, а не выключать мозг.

Ошибка №2: проверять только репозитории и игнорировать сервисный сценарий.
Hibernate-проблемы живут не в «методах репозитория сами по себе», а в связке: сервисный use case → транзакция → чтение/запись → навигация по графу. Если ревью сводится к Repository-файлам, вы пропустите половину реальных рисков, особенно overscoped transactions и hidden lazy traversal.

Ошибка №3: ловить красные флажки только в entity-аннотациях.
Конечно, EAGER, CascadeType.ALL и странные mappedBy — важны. Но очень много красных флажков живут в «обычном Java-коде»: stream().map(o -> o.getCustomer()...), toString() с коллекциями, мапперы, которые обходят весь граф, bulk update без очистки контекста. Чеклист должен смотреть на код целиком.

Ошибка №4: смешивать read и write требования в одну кашу.
Для read-path нормально хотеть projection и минимум managed-сущностей. Для write-path нормально хотеть managed root-entity и find + mutate. Если в ревью мы одним и тем же набором претензий «мочим» и чтения, и записи, появляется хаос: автор не понимает, что именно от него хотят, а слой данных становится непоследовательным.

Ошибка №5: забывать, что красный флажок — это ещё не баг, но почти всегда долг.
Иногда PR с red flag действительно «работает». Но red flag означает, что поведение либо дорогое, либо хрупкое, либо непредсказуемое. Если вы оставляете его без обсуждения, вы не просто мёрджите код — вы мёрджите будущие ночные дежурства с логами SQL. Hibernate, конечно, мощный инструмент, но он не обязан быть вашим ночным собеседником.

1
Задача
Hibernate deep-dive, 30 уровень, 2 лекция
Недоступна
REST-чеклист для ORM-review
REST-чеклист для ORM-review
1
Задача
Hibernate deep-dive, 30 уровень, 2 лекция
Недоступна
Синхронизация двусторонней связи заказа и позиции
Синхронизация двусторонней связи заказа и позиции
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ