Підхід find ( ) + mutate

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

1. Вступ

Почнімо з дуже буденної людської проблеми. У реальному застосунку оновлення майже ніколи не виглядає як «ось вам акуратний об’єкт PurchaseOrder, повністю заповнений, беріть і зберігайте». Частіше прилітає фрагмент даних: «змініть статус», «змініть кількість в одній позиції», «оновіть коментар менеджера». І тут раптом з’ясовується, що сліпо застосувати весь об’єкт цілком — чудовий спосіб випадково стерти те, чого ми взагалі не збиралися чіпати.

merge() уміє повернути detached-об’єкт у managed-світ, копіюючи його стан у managed-екземпляр. Але проблема в тому, що він копіює саме той стан, який йому принесли. Якщо вам принесли «шматок» — частково заповнений граф, — ви ризикуєте скопіювати в базу не правку, а випадкову втрату даних. Саме тут і з’являється підхід find() + mutate: спочатку беремо актуальний стан із БД як managed-сутність, а потім явно змінюємо лише те, що справді потрібно і дозволено.

Важливо розуміти: цей підхід не означає «замість Hibernate» і не стверджує, що merge() поганий. Він потрібний тому, що бізнес-оновлення — це зазвичай не повторне збереження об’єкта, а команда на зміну конкретних полів.

2. Модель: від БД до managed

Якщо merge() часто виглядає як «візьми те, що мені дали, і зроби так, щоб це стало правдою в базі», то find + mutate виглядає значно спокійніше. Ми кажемо Hibernate: «Дай мені поточну сутність із бази», отримуємо managed-об’єкт, а далі працюємо з ним як зі звичайним об’єктом, розуміючи, що dirty checking потім сам сформує потрібний UPDATE.

Ця модель дуже близька до того, як Hibernate взагалі очікує, що ви працюватимете у звичайному unit of work: знайшли сутність, змінили, закомітили. В офіційних прикладах це буквально виглядає як entityManager.find(...), потім виклик сетера — і далі Hibernate надсилає UPDATE.

Щоб картинка склалася, уявіть дві ролі:

База даних — джерело істини: що є зараз (what is).
Команда або DTO із зовнішнього світу — намір зміни: що має змінитися (what should change).

find + mutate якраз і з’єднує what is і what should change найпрямішим способом.

Невелика схема процесу:

flowchart TD
    %% Спочатку беремо managed-сутність, потім змінюємо її точково — і лише після flush/commit Hibernate генерує UPDATE
    A["Початок бізнес-операції (unit of work)"] --> B["find(orderId) -> managed PurchaseOrder"]
    B --> C["Перевірки / валідація (чи можна змінювати?)"]
    C --> D["mutate: змінюємо лише потрібні поля"]
    D --> E["flush/commit -> Hibernate генерує SQL UPDATE"]

Тут немає магії. Просто послідовність дій, яку на code review можна пояснити не гірше, ніж рецепт омлету.

3. Каркас update-сценарію

Тепер — трохи буденної інженерії. Якщо ви хочете, щоб find + mutate працював передбачувано, потрібно зробити дві речі: виконувати операцію в одному робочому контексті, тобто в транзакції, і гарантувати, що ви справді працюєте з managed-об’єктом. У Spring-світі це найчастіше означає сервісний метод із @Transactional, який і задає межу unit of work: всередині неї ви читаєте та змінюєте сутності.

Документація Spring Data прямо показує ідею сервісу або фасаду, який задає транзакційні межі для операцій із кількома викликами репозиторія. Нам це зараз ідеально підходить: ми хочемо, щоб find() і наступні зміни жили в одній транзакції.

Мінімальний каркас сервісу — спрощений, але по суті такий:

import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderCommandService {

    // EntityManager — це точка входу в persistence context (контекст, де сутності стають managed)
    private final EntityManager entityManager;

    // Передаємо EntityManager, щоб працювати з find()/flush та іншими операціями
    public OrderCommandService(EntityManager entityManager) {
        this.entityManager = entityManager;
    }

    @Transactional // Важливо: find() і mutate мають жити в одній транзакції (в одному unit of work)
    public void markPaid(long orderId) {
        // find() повертає managed-сутність (або null, якщо запису немає)
        PurchaseOrder order = entityManager.find(PurchaseOrder.class, orderId);
        if (order == null) throw new IllegalArgumentException("Замовлення не знайдено: " + orderId);

        // Змінюємо поле у managed-сутності — Hibernate зловить це через dirty checking і сформує UPDATE на flush/commit
        order.setStatus(OrderStatus.PAID);
    }
}

Зверніть увагу на дві деталі. По-перше, find() може повернути null, і це не випадковість, а нормальна гілка поведінки: запису може не бути. По-друге, після find() об’єкт уже managed, а отже зміна його полів — це зміна стану persistence context, а не просто «змінили змінну в пам’яті й забули».

Якщо хочеться наочності, особливо на перших порах, можна тимчасово перевірити, чи об’єкт під керуванням:

PurchaseOrder order = entityManager.find(PurchaseOrder.class, orderId);

// contains(...) показує: чи перебуває об'єкт під керуванням поточного persistence context
System.out.println(entityManager.contains(order)); // true

Так ви буквально бачите: так, цей об’єкт під наглядом Hibernate.

4. Selective mapping: patch без втрат

Тепер — найважливіша частина підходу. find + mutate майже завжди йде пліч-о-пліч з ідеєю selective mapping: ми не копіюємо весь об’єкт цілком, а переносимо лише те, що справді є командою на зміну. Інакше дуже швидко виникне ситуація в дусі: «у DTO не було поля deliveryAddress, отже воно приїхало як null, отже ми перезаписали адресу доставки в базі на null». Вітаю: ви щойно винайшли баг, який спливатиме раз на два тижні й обов’язково у VIP-клієнта.

Тому ми вводимо окремий об’єкт команди — щось на кшталт patch-команди.

Ось спрощений приклад команди для замовлення:

import com.example.commerce.orders.entity.OrderStatus;

// Patch-команда: несе лише те, що ми потенційно хочемо змінити
// У навчальному варіанті домовимося: null = "поле не передали, отже не змінюємо"
public record OrderPatchCommand(OrderStatus status, String customerNote) {
}

Далі робимо метод, який застосовує зміни за правилами. Найпростіше правило для навчального проєкту: null означає «не змінюємо».

public void applyPatch(PurchaseOrder order, OrderPatchCommand cmd) {
    // Змінюємо статус лише якщо він справді прийшов у команді
    if (cmd.status() != null) {
        order.setStatus(cmd.status());
    }

    // Те саме для нотатки — відсутність поля не повинна затирати значення в БД
    if (cmd.customerNote() != null) {
        order.setCustomerNote(cmd.customerNote());
    }
}

Важливий момент: цей код виглядає нудно. І це комплімент. Нудний код у persistence layer зазвичай означає «передбачуваний». А передбачуваність у Hibernate — це майже як страховка: не тішить щодня, але одного дня рятує проєкт.

Є й тонше питання, яке вам рано чи пізно поставлять на роботі: «А якщо null — це осмислене нове значення, тобто ми хочемо обнулити поле?» Тоді null уже не може означати «не змінюємо». Для таких випадків зазвичай вводять окремі маркери — наприклад, Optional, окремий boolean «present» або повноцінний JSON Patch. Але в межах нашого поточного рівня достатньо чесно домовитися: null = «поле не передали, не чіпаємо».

5. Managed-об’єкт і UPDATE без save()

Тут важливо поєднати дві вже знайомі речі — dirty checking і flush — з нашим сьогоднішнім патерном. Коли ви отримали сутність через find(), вона стала managed. Це означає, що Hibernate вважає її частиною поточного persistence context і порівнюватиме її поточний стан зі snapshotʼом, який зберіг під час завантаження. Якщо ви змінили поле — він підготує UPDATE.

У документації Hibernate такий сценарій показують буквально одним рядком: знайшли сутність, змінили поле — і далі йде UPDATE.

Звідси випливає практична звичка: якщо ви в сервісі робите find + setSomething(...), то механічний repository.save(entity) після цього найчастіше не потрібен. А інколи він навіть шкідливий, бо може випадково затягнути вас у merge-семантику, якщо об’єкт раптом detached.

Щоб побачити, що саме відбувається, корисно вмикати SQL trace і спостерігати типовий шаблон:

1) SELECT ... WHERE id=? — це find(),
2) UPDATE ... — це dirty checking під час flush.

І так, UPDATE зазвичай піде не в момент виклику сетера, а пізніше — ближче до flush або commit. Це не баг, а архітектура unit of work.

6. Зв’язки та колекції: точкові правки

Тепер переходимо до місця, де find + mutate особливо доречний: коли в сутності є пов’язані дані. Замовлення (PurchaseOrder) майже напевно має позиції (OrderItem). І ось тут у початківця з’являється дуже сильна спокуса: «Мені прийшов DTO зі списком items — я просто зроблю order.setItems(newItems)». Це виглядає красиво рівно до першого бага, коли ви раптом видалили позиції, які користувач не хотів видаляти, або створили дублікати, або запустили каскади та сирітські видалення, або просто отримали неочікуваний обсяг SQL.

Безпечніша інтуїція в стилі find + mutate така: ми знайшли поточне замовлення, а далі точково змінюємо потрібну позицію. Наприклад, змінюємо кількість конкретної позиції за itemId.

public void changeItemQty(PurchaseOrder order, long itemId, int qty) {
    // Шукаємо конкретну позицію в поточному стані замовлення (не перезаписуємо всю колекцію)
    for (OrderItem item : order.getItems()) {
        // Важливо: ми змінюємо рівно одну позицію, а не перебудовуємо весь граф
        if (item.getId().equals(itemId)) {
            item.setQuantity(qty); // managed-сутність/елемент колекції -> dirty checking спрацює на flush
            return;
        }
    }

    // Явно обробляємо ситуацію "позицію не знайдено", щоб не робити вигляд, що оновлення відбулося
    throw new IllegalArgumentException("Позицію не знайдено: " + itemId);
}

Цей код простий, але виражає важливу думку: «Ми змінюємо конкретний рядок або об’єкт, а не перебудовуємо весь граф». Для навчального проєкту це ідеальна дисципліна: ви менше залежите від каскадів і менше ризикуєте «полагодити одне, зламавши ще три».

Якщо потрібно додати нову позицію, підхід аналогічний: не замінювати колекцію цілком, а додати один елемент. У реальному проєкті це зазвичай роблять через helper-метод на сутності, щоб не порушувати консистентність зв’язку, але в межах сьогоднішньої теми важливіше зрозуміти сам принцип: зміна має бути локальною і навмисною.

І так, це може виглядати багатослівніше, ніж merge(). Але це якраз той випадок, коли багатослівність — ціна за контроль.

7. SQL trace: SELECT і UPDATE на flush

Зараз ми зв’яжемо логіку патерна зі спостережуваним SQL, щоб ви могли не вірити мені на слово — і взагалі ніколи не вірити на слово в ORM-темах. find() майже завжди означає читання поточного стану з бази, тобто SELECT. Після цього ви змінюєте поля, але SQL може не вилетіти одразу. Він піде на flush або commit, або раніше, якщо спрацює тригер flush — це ви вже знаєте.

Схематично SQL-профіль виглядає так:

-- find(): читаємо поточний стан із таблиці
select id, status, customer_id
from purchase_order
where id = ? -- ? підставиться JDBC/ORM як параметр

-- пізніше, під час flush/commit: Hibernate надішле мінімально потрібний UPDATE
update purchase_order
set status = ?
where id = ?

Саме цей двофазний характер — спочатку читаємо managed-стан, потім надсилаємо зміни — робить find + mutate таким передбачуваним. Він дорогий? Іноді так, бо майже завжди включає SELECT. Але дуже часто це той самий SELECT, який merge() усе одно може виконати приховано, адже Hibernate може читати поточний стан перед застосуванням merge, щоб гарантувати коректну семантику.

Різниця в тому, що з find + mutate ви цей SELECT бачите в коді, а отже можете обговорювати й контролювати.

8. Коли find + mutate особливо корисний

Існує небезпечне переконання: «Якщо є зайвий SELECT, значить ми погано оптимізували». Воно особливо популярне серед людей, які щойно дізналися слово «performance» і тепер хочуть усюди UPDATE без читання. У persistence layer це часто закінчується тим, що ви оптимізуєте не продуктивність, а швидкість появи багів.

find + mutate особливо доречний, коли:

— ви робите часткове оновлення і змінюєте кілька полів, не чіпаючи решти;
— вам важливі інваріанти, наприклад не можна переводити замовлення в PAID, якщо воно вже CANCELLED;
— вхідні дані неповні або «брудні» — звичайний світ інтеграцій і UI;
— ви хочете, щоб код було легко читати на code review без вгадування «а що там merge() зараз зачепить каскадом».

Hibernate у своїх матеріалах прямо підкреслює, що merge() обмінює detached-екземпляр на persistent-екземпляр і що вихідний об’єкт залишається detached. А ще зазначає, що можна взяти під контроль отримання стану під час merge, викликавши find() до merge(). На практиці це ще один натяк: find — не «зайва робота», а фундаментальний спосіб стабілізувати поведінку.

Якщо сказати по-людськи: find + mutate — це коли ви спочатку дивитеся на реальну ситуацію, тобто на поточний стан рядка, і лише потім вирішуєте, що змінювати. А не навпаки.

9. Типові помилки під час find + mutate

Помилка №1: «Я знайшов managed-об’єкт і все одно зробив merge()/save() про всяк випадок».
Це виглядає як страховка, але часто це просто зайва операція і зайва плутанина в голові. Якщо об’єкт managed, брудні зміни й так будуть виявлені. У результаті ви або робите зайву роботу, або неявно переводите сценарій у merge-семантику там, де вона не потрібна. Особливо прикро, коли після цього починають спливати неочікувані SELECT: ви ніби прийшли за передбачуваністю, а самі ж її розмили.

Помилка №2: «null у команді означає “обнулити”, але я трактую його як “не змінювати” — або навпаки».
Це не баг Hibernate, це баг домовленості. У команди на оновлення має бути чітка семантика: що означає відсутність поля, що означає null, що означає порожній рядок. Якщо ви явно не домовилися про це, selective mapping перетворюється на генератор сюрпризів. У навчальному проєкті найпростіше домовитися, що null = «не змінюємо», і далі тримати це правило залізно.

Помилка №3: «Оновлю одну позицію замовлення, тому заміню всю колекцію items».
Це найчастіша логічна помилка масштабу. Здається, що ви оновлюєте одну позицію, а по факту змінюєте цілу структуру даних, що може викликати каскади, видалення, вставки й просто величезний обсяг SQL. find + mutate тим і добрий, що спонукає до точкових змін: знайшов замовлення, знайшов item, змінив кількість.

Помилка №4: «Забув обробити сценарій “сутність не знайшли”».
find() може повернути null. Репозиторій може повернути Optional.empty(). Це нормально. Ненормально — продовжувати роботу так, ніби сутність існує. У кращому випадку ви отримаєте NPE, у гіршому — спробуєте створити «оновлення» там, де його бути не повинно, і бізнес-логіка почне жити в режимі «ну, якось».

Помилка №5: «Зробив find в одному місці, а mutate — в іншому, уже без транзакції».
Так ви легко перетворюєте managed-сценарій назад на detached-flow, тільки менш помітно. find + mutate передбачає, що читання і зміна відбуваються в одному unit of work. Якщо ви рознесли їх по різних місцях і втратили цю межу, то знову опинитеся у світі detached-об’єктів, де зміни «в пам’яті є», а в базі — ні. Hibernate окремо підкреслює: при detachment об’єкт можна змінювати, але persistence context уже не дізнається про це автоматично, і застосунку доводиться втручатися явно.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ