1. Порівняння на одному сценарії
Тепер картина цілісна: можна або принести в систему detached-стан і спробувати злити його через merge(), або спершу взяти managed-замовлення і застосувати до нього команду на зміну. Зведімо обидва підходи до одного сценарію редагування в бекофісі — не заради красивих слів, а заради спостережуваної поведінки: які об’єкти будуть managed, де з’явиться SELECT і що саме Hibernate вирішить оновити під час flush().
Уявіть екран «Редагування замовлення». Оператор змінює статус і коригує кількість кількох конкретних позицій. Це типова операція «точково підправити агрегат», і саме на таких часткових оновленнях найчастіше й спливає магія рівня «чому воно оновило не те?».
Сервіс отримує не повний знімок сутності, а команду з тим, що користувач справді змінив. І ось тут починається головна відмінність: merge() сприймає вхід як джерело стану для копіювання, а find() + mutate — як набір конкретних змін. Ці дві моделі звучать схоже рівно доти, доки не стикаються з неповним входом.
2. Один вхід для обох шляхів
Візьмемо одну й ту саму команду та проганятимемо її двома способами. У ній немає повного списку полів замовлення і немає повного списку items — лише те, що оператор реально змінив.
package com.example.commerce.orders.dto;
import java.util.List;
public record OrderEditRequest(
Long orderId, // id замовлення, яке редагуємо
String newStatus, // новий статус (наприклад, "PAID", "CANCELLED")
List<ItemQtyPatch> itemPatches // патчі за позиціями: змінюємо кількість лише для перелічених itemId
) {
public record ItemQtyPatch(
Long itemId, // id позиції замовлення (OrderItem)
int newQty // нова кількість для цієї позиції
) {}
}
Саме цей request піде і в гілку з merge(), і в гілку з find() + mutate. Жодних різних DTO під кожен підхід — інакше порівняння занадто швидко перетворюється на підміну умов.
3. Варіант A: merge()
Потік merge() — «копіюємо стан detached-об’єкта»
Почнемо з потоку, який трактує вхід як detached-стан. Базовий контракт тримаємо в голові коротко: merge() копіює стан у managed-екземпляр, і якщо після цього ви хочете продовжувати роботу, то вже з поверненим об’єктом. Але зараз цікавіше інше: що станеться, якщо таким станом виявиться частковий граф замовлення.
package com.example.commerce.orders.service;
import com.example.commerce.orders.dto.OrderEditRequest;
import com.example.commerce.orders.entity.OrderItem;
import com.example.commerce.orders.entity.OrderStatus;
import com.example.commerce.orders.entity.PurchaseOrder;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderEditingMergeService {
private final EntityManager entityManager;
public OrderEditingMergeService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional
public void editOrderWithMerge(OrderEditRequest request) {
PurchaseOrder detached = new PurchaseOrder();
detached.setId(request.orderId());
detached.setStatus(OrderStatus.valueOf(request.newStatus()));
detached.setItems(
request.itemPatches().stream()
.map(patch -> {
// У позиції є лише id і qty: це частковий граф, а не повний знімок замовлення
OrderItem item = new OrderItem();
item.setId(patch.itemId());
item.setQuantity(patch.newQty());
return item;
})
.toList()
);
entityManager.merge(detached);
entityManager.flush();
}
}
На рівні Java все виглядає компактно: із request швидко зібрали об’єкт і передали його в ORM. Але для Hibernate це вже не «зміни статус і ось ці qty», а «ось стан замовлення та його позицій». А стан у нас неповний: у замовлення немає решти полів, а в позицій — повного контексту зв’язку та інших даних.
Саме тому itemPatches тут легко перестають бути списком точкових змін і починають виглядати як новий варіант колекції items, який потрібно синхронізувати.
SQL під час merge()
За кодом дуже хочеться повірити, що далі буде «один UPDATE замовлення і ще пара UPDATE по позиціях». На практиці merge() часто спершу читає поточне замовлення та його колекцію, щоб отримати managed-представників і зрозуміти, що саме синхронізувати.
Тому типовий SQL-профіль merge-сценарію виглядає приблизно так:
-- merge отримує managed-корінь замовлення
select po.id, po.status
from purchase_order po
where po.id = ?;
-- а потім розбирається з поточним складом колекції items
select oi.id, oi.order_id, oi.quantity
from order_item oi
where oi.order_id = ?;
-- лише після цього відправляються зміни
update purchase_order
set status = ?
where id = ?;
update order_item
set quantity = ?
where id = ?;
І це ще оптимістичний варіант. Якщо detached-граф виявився біднішим, ніж реальне замовлення в БД, сюди легко додаються зайві читання, неочікувані UPDATE і конфлікт копій.
Є ще один ефект, який у таких порівняннях часто пропускають: якщо замовлення вже завантажене в поточний persistence context, merge() копіюватиме стан саме в нього. Тобто бідний detached-patch може перезаписати більш повну managed-сутність, з якою ви вже працювали в цій самій транзакції.
import jakarta.persistence.EntityManager;
// managed1 вже в persistence context (відстежується Hibernate)
PurchaseOrder managed1 = entityManager.find(PurchaseOrder.class, 100L);
// detached - окремий екземпляр із тим самим id (наприклад, прийшов із мапера/DTO)
PurchaseOrder detached = new PurchaseOrder();
detached.setId(100L);
// merge поверне посилання на той самий managed-об'єкт, який уже лежав у persistence context
PurchaseOrder managed2 = entityManager.merge(detached);
System.out.println(managed1 == managed2); // true
Це означає таке: якщо ви в межах однієї транзакції вже щось змінили в managed1, а потім випадково викликали merge() з detachedPatch, то можете перезаписати власні ж зміни біднішим станом detached-об’єкта. І виглядатиме це як «Hibernate збожеволів», хоча насправді він просто чесно виконав контракт merge(): скопіював стан.
4. Варіант B: find() + mutate
Тепер проганяємо той самий OrderEditRequest через інший потік. Тут request — це не «майже-сутність», а команда: спершу читаємо поточне замовлення з БД, потім застосовуємо лише ті зміни, які справді надійшли.
package com.example.commerce.orders.service;
import com.example.commerce.orders.dto.OrderEditRequest;
import com.example.commerce.orders.entity.PurchaseOrder;
import com.example.commerce.orders.entity.OrderItem;
import com.example.commerce.orders.entity.OrderStatus;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class OrderEditingService {
private final EntityManager entityManager;
public OrderEditingService(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional
public void editOrderFindAndMutate(OrderEditRequest request) {
// Явно завантажуємо замовлення: тепер це managed-сутність (джерело істини)
PurchaseOrder order = entityManager.find(PurchaseOrder.class, request.orderId());
// Застосовуємо зміни точково: змінюємо лише те, що надійшло як "команда"
order.setStatus(OrderStatus.valueOf(request.newStatus()));
// Важливо: оновлюємо лише перелічені itemId, решти позицій не торкаємося
for (var patch : request.itemPatches()) {
OrderItem item = entityManager.find(OrderItem.class, patch.itemId());
item.setQuantity(patch.newQty());
}
// Примусово показуємо SQL і ефект dirty checking
entityManager.flush();
}
}
Прямий find(OrderItem.class, patch.itemId()) тут потрібний лише для чесного порівняння SQL один до одного. У реальному сценарії оновлення все одно важливо перевірити, що позиція справді належить цьому замовленню, або змінювати її через агрегат і helper-метод. Інакше навіть безпечний потік швидко знову перетворюється на історію «знайшли будь-який child за id і тихо змінили його».
Так, рядків більше. Зате кожен із них відображає конкретну зміну: знайшли замовлення, змінили статус, знайшли перелічені позиції, змінили кількість. Якщо в request немає інших полів і інших items, Hibernate й не отримує приводу трактувати їх як новий стан усього графа.
5. Порівняння: SQL і сенс
Тепер порівнюємо не дві абстракції, а реакцію Hibernate на один і той самий OrderEditRequest. Питання вже не лише в кількості запитів, а й у тому, наскільки передбачувано кожна гілка трактує частковий вхід.
З точки зору SQL, find() + mutate часто виглядає так:
-- явно читаємо замовлення як поточну істину
select ... from purchase_order where id = ?;
-- потім читаємо лише ті позиції, які справді надійшли в патчі
select ... from order_item where id = ?;
-- а на flush йде рівно те, що змінили
update purchase_order set status = ? where id = ?;
update order_item set quantity = ? where id = ?;
З точки зору бізнес-сенсу різниця така: у find() + mutate ви частіше мислите командами зміни, а в merge() — передачею стану. Поки вхід повний, це терпимо. Щойно request несе лише статус і кілька itemPatches, merge() починає здогадуватися за вас, чого ви не хотіли чіпати.
Щоб зафіксувати порівняння максимально наочно, давайте зберемо «паспорт відмінностей» у таблицю.
| Критерій | merge() (detached → копіювання стану) | find() + mutate (завантаження → застосування змін) |
|---|---|---|
| Джерело істини | Detached-обʼєкт, який ви принесли | БД → managed-сутність у persistence context |
| Що потрібно від вхідних даних | Бажано повний знімок замовлення та потрібного графа | Можна надіслати лише те, що змінюється |
| Робота з поверненим обʼєктом | Потрібно обовʼязково використовувати обʼєкт, який повернув merge() | Працюєте з одним managed-екземпляром |
| Ризик «випадкового перезапису» | Високий, особливо при partial DTO і графах | Нижчий: ви змінюєте лише те, що явно написали в коді |
| Видимість SQL «у коді» | Частина SELECT прихована всередині merge() | SELECT зазвичай явний — через find() |
| Дебаг і code review | Складніше: «що саме копіюємо?» | Простіше: «ось які поля чіпаємо» |
І тепер найважливіше: це не таблиця «merge() поганий, find() хороший». Це таблиця «merge() вимагає дисципліни й повноти даних, а find() + mutate — безпечніший дефолт для бізнес-змін».
6. Вибір підходу
Щоб не тримати все це в голові як набір розрізнених думок, зручно мати маленьку блок-схему вибору. Вона не про «правильно / неправильно», а про те, що ви реально контролюєте: повноту входу та передбачуваність результату.
flowchart TD
A["Потрібно оновити замовлення"] --> B{"Вхідні дані — повний знімок замовлення (усі важливі поля + весь потрібний граф)?"}
B -->|Так| C{"Ви готові після merge працювати лише з поверненим managed-екземпляром і читати SQL trace?"}
C -->|Так| D["Можна використовувати merge (але обережно, без змішування потоків)"]
C -->|Ні| E["Краще find + mutate (менше магії, простіше підтримувати)"]
B -->|Ні| E["find + mutate (вибіркове оновлення)"]
У звичайному редагуванні в бекофісі вхід майже завжди неповний: оператор змінив одне поле, UI надіслав патч. У такій моделі find() + mutate виграє не елегантністю, а інженерною передбачуваністю. merge стає доречнішим там, де detached-об’єкт справді є акуратним повним знімком і ви не намагаєтеся використовувати entity як PATCH-документ.
Є ще одна важлива дисципліна: не змішувати потоки в одному методі. Типовий компроміс, який виглядає розумно, але зазвичай приводить до плутанини, звучить так: «Я зроблю merge(), а потім ще вручну поправлю поля». Проблема в тому, що тепер у вас два посилання — detached і managed, — і ви легко можете почати змінювати не те. А ще можна неусвідомлено перезаписати результат власних ручних змін черговим merge() іншого об’єкта.
7. Типові помилки під час порівняння merge() і find() + mutate
Помилка №1: використовувати merge() як «часткове оновлення за id».
Дуже часта пастка: розробник створює новий PurchaseOrder, задає лише id, status і пару itemPatches, робить merge() і сподівається, що Hibernate оновить лише це. Hibernate так не вважає: він копіює стан цілком. Якщо решту полів і зв’язків не задано, ви або затрете їх значеннями за замовчуванням, або отримаєте помилку обмежень на flush().
Помилка №2: ігнорувати повернений обʼєкт із merge().
Це той випадок, коли один рядок коду економить вам кілька годин: завжди зберігайте результат merge() у змінну і продовжуйте роботу лише з ним. Якщо ви далі змінюватимете старий detached-екземпляр, ці зміни не потраплять під dirty checking, і ви щиро дивуватиметеся, чому «Hibernate нічого не зберіг».
Помилка №3: змішувати merge() і find() + mutate в одному update-потоці «про всяк випадок».
Коли в одному методі одночасно трапляються find(), потім merge(), потім знову прямі зміни, у вас виникає подвійна бухгалтерія сутностей. У такій ситуації навіть досвідченому розробнику може бути непросто зрозуміти, який об’єкт зараз managed, який detached і що саме опиниться в БД після flush().
Помилка №4: обирати merge() лише тому, що «так менше рядків».
Кількість рядків — поганий критерій для persistence-коду. У merge() більша частина складності захована не в Java-рядках, а в прихованих діях: створення або пошук managed-екземпляра, можливі читання з БД, каскади на граф і копіювання стану колекцій. find() + mutate зазвичай довший, але зате краще пояснюється і легше діагностується.
Помилка №5: порівнювати підходи без перегляду SQL trace.
На рівні Java-коду обидва підходи можуть виглядати приблизно однаково. Різниця часто проявляється в SQL-сліді: де зʼявилися зайві SELECT, чому раптом оновилося більше колонок, ніж ви очікували, або чому update зачепив не лише кореневу таблицю. Якщо ви порівнюєте merge() і find() + mutate, але не дивитеся SQL, ви порівнюєте відчуття, а не поведінку.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ