1. Патерни рефакторингу: маленькі кроки
Якщо ви бодай раз пробували «навести лад у шарі збереження даних», то знаєте класичний сценарій: відкриваєте сервіс, бачите 300 рядків, 12 викликів репозиторію і десь між ними saveAndFlush(), а потім думаєте: «Ну… давайте спочатку замінимо LAZY на EAGER, раптом допоможе». Це нормальна людська реакція, але до доброго коду вона рідко приводить. Саме для цього нам і потрібні refactoring patterns: щоб мислення перестало метатися.
Refactoring pattern у контексті Hibernate — це маленьке, повторюване поліпшення, яке робить поведінку ORM передбачуванішою. Не «прискорить усе у 10 разів» і не «прибере всі баги назавжди», а саме зменшить область невизначеності. Hibernate і так «розумний» — іноді навіть занадто, — тому наше головне завдання полягає в тому, щоб зробити його розумним у зрозумілих межах.
Три патерни тут корисні саме тим, що добре лікують три типові проблеми.
Read-model split лікує entity leakage: ви перестаєте віддавати entity назовні, коли вам потрібно просто показати список або зробити експорт. Entity лишається write-моделлю, а читання отримує власну форму даних.
Явний запит (explicit query) лікує «магічний репозиторій»: замість надії, що findById() сам собою «правильно все завантажить», ви прямо фіксуєте форму читання і fetch-план під use case.
Вужчий unit of work лікує overscoped transactions: транзакція перестає охоплювати «все підряд», а Hibernate перестає жити занадто довго й робити SQL у неочікувані моменти.
Щоб це запам’яталося не як три гасла, а як робочий інструмент, зафіксуймо просту табличку — не як чекліст «робіть так», а як карту «що лікуємо чим».
| Симптом у коді | Що зазвичай відчувається в проді | Який refactoring move найчастіше допомагає | Типовий приклад у Commerce Persistence Lab |
|---|---|---|---|
| PurchaseOrder | непередбачувані lazy-підвантаження, LazyInitializationException або «чому так багато SQL?» | read-model split | список замовлень / summary замовлення |
| findById() | а далі — «ходить по графу» | N+1, вторинні select-и, дивна ціна «просто читання» | findForEditingById(), findSummaryById() |
| Один метод транзакційний і робить усе: читає, змінює, форматує, логує | довгі транзакції, зайвий flush, блокування, складна діагностика | smaller unit of work | закриття замовлень + генерація звіту |
Спочатку корисно подивитися на кожен move окремо, а потім — як вони збираються в один сценарій «до/після», щоб було видно: це не теорія, а цілком конкретні шматки коду.
2. Read-model split: читання vs запис
Коли починаєш писати проєкт на Spring Data JPA, дуже хочеться зробити так: «усе буде entity, а DTO — потім». Деякий час це навіть працює. Але ближче до реальності виникає неприємна властивість: entity — річ «жива». Вона може бути managed, може бути proxy, може ліниво підвантажувати зв’язки і може брати участь у dirty checking. Якщо ви віддаєте entity туди, де вона не має жити, то буквально випускаєте кота на вулицю: чи повернеться — невідомо, а сюрпризи принесе точно.
Суть read-model split проста: entity — це write-модель, тобто форма даних, зручна для зміни всередині транзакції. А read use case — список, картка summary, експорт, звіт — має отримувати свою форму, зазвичай у вигляді DTO або projection. Це не «DDD-релігія», а практична санітарія: так ви не тягнете managed-граф туди, де потрібен просто набір колонок.
У нашому Commerce Persistence Lab типовий приклад — список товарів для адмінки. Для списку нам зазвичай потрібні id, sku, name, можливо status і ціна. А ProductDetails, призначення по категоріях, аудит і решта — це для інших use cases. Тому ми робимо вузьку read-модель.
Приклад: простий DTO для списку товарів
Почнемо з record (у Java 25 це дуже зручна форма «дані без зайвої філософії»):
package com.example.commerce.catalog.dto;
// DTO для списку: лише те, що потрібно для табличного виведення, без entity-графа і лінивих зв’язків.
public record ProductListRow(Long id, String sku, String name) {
}
Зверніть увагу: це не «універсальний DTO на всі випадки життя». Він спеціально нудний і короткий. Якщо DTO починає розростатися до «все про товар, включно з думками автора», він перетворюється на гігантський DTO і повторює долю гігантського entity graph.
Приклад: query-репозиторій, який повертає DTO
Далі ми робимо query-орієнтований репозиторій. Його завдання — не «все CRUD», а рівно один read-сценарій: отримати рядки для списку.
package com.example.commerce.catalog.query;
import com.example.commerce.catalog.dto.ProductListRow;
import com.example.commerce.catalog.entity.Product;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import java.util.List;
// Репозиторій для читання: повертаємо DTO і тим самим фіксуємо форму даних.
public interface ProductQueryRepository extends Repository<Product, Long> {
// Запит одразу збирає "рядки" для UI: Hibernate не зможе випадково дотягнути зв’язки через гетери.
@Query("""
select new com.example.commerce.catalog.dto.ProductListRow(p.id, p.sku, p.name)
from Product p
where p.status = 'ACTIVE'
order by p.name
""")
List<ProductListRow> findActiveRows();
}
Тут одразу кілька важливих моментів, які допомагають саме Hibernate-мисленню.
По-перше, запит фіксує форму даних. Hibernate не може «випадково» завантажити ProductDetails і колекції, тому що ми не завантажуємо entity — ми будуємо DTO напряму.
По-друге, це майже завжди дешевше: менше колонок у SQL, менше даних у мережі між БД і застосунком, менше об’єктів у пам’яті, менше накладних витрат на persistence context.
По-третє, зникає частина випадкових побічних ефектів. DTO не є lazy-проксі, DTO не «managed». Він просто дані.
Приклад: read-сервіс, який НЕ повертає entity
Тепер робимо сервіс читання. Тут ключовий момент — не «обгорнути репозиторій сервісом заради архітектурної галочки», а задати межу: read-сервіс повертає read-модель.
package com.example.commerce.catalog.service;
import com.example.commerce.catalog.dto.ProductListRow;
import com.example.commerce.catalog.query.ProductQueryRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class ProductReadService {
private final ProductQueryRepository productQueryRepository;
public ProductReadService(ProductQueryRepository productQueryRepository) {
this.productQueryRepository = productQueryRepository;
}
@Transactional(readOnly = true) // Явно позначаємо: це читання, без наміру щось змінювати й виконувати flush.
public List<ProductListRow> listActiveProducts() {
return productQueryRepository.findActiveRows();
}
}
@Transactional(readOnly = true) тут не «тому що так гарніше». Ми вже обговорювали, що для Hibernate режим read-only впливає на flush expectations і може зменшувати зайві накладні витрати на dirty checking. Але головне навіть не в оптимізації, а в семантиці: ми явно говоримо «це читання» і не перетворюємо метод на прихований write-сценарій.
Важливий нюанс: projection теж може розкрити join ширше, ніж здається
Виглядає спокусливо написати щось на кшталт ProductListRow(Long id, String sku, String categoryName) і «просто дістати ім’я категорії». Але щойно DTO містить поля зі зв’язаних сутностей, ви вже почали вибирати fetch-форму запиту. Це нормально, просто робіть це свідомо.
Якщо ви додаєте c.name, вам знадобиться join, і SQL стане ширшим. Це не добре і не погано — це рішення. Проблема починається тоді, коли розробник думає: «Я ж повернув DTO, отже все безпечно», а насправді зробив запит із великим join, який тягне більше рядків і може роздувати результат. Read-model split не скасовує потреби думати про форму запиту — він просто робить цю форму явнішою.
Мінісхема: що змінюється при read-model split
flowchart TD
A["Сервіс"] -->|повертає entity| B["Зовнішній код"]
B -->|випадково чіпає lazy| C["SQL-сюрпризи"]
A2["Read Service"] -->|повертає DTO| B2["Зовнішній код"]
B2 -->|DTO не можна дочитати| D["Передбачувана поведінка"]
Якщо вам потрібно запам’ятати одну думку з цього розділу, нехай буде така: entity — це не «формат даних», а «об’єкт під керуванням ORM». Тому віддавати entity назовні для звичайних read-сценаріїв майже завжди підозріло.
Giant graph: не кожен випадок лікується лише fetch-планом
Тут корисно розділити дві схожі проблеми. Іноді giant graph з’являється тому, що read-case намагаються обслуговувати entity-графом: списку або summary просто не потрібен увесь PurchaseOrder, і тоді допомагають read-model split та явний query. Але буває й інша історія: root сам по собі виявився занадто широким і тягне за собою колекції, каскади та lifecycle-правила, яким не обов’язково жити разом.
Aggregate boundary тут — це просто межа того, що справді має змінюватися атомарно разом в одному unit of work. Якщо замовлення майже в кожному write-case тягне за собою півпроєкту, проблема вже не тільки у fetch-плані. Ви, можливо, випадково зробили один величезний lifecycle-шматок там, де краще було б тримати вужчі межі.
| Що видно | Що це найчастіше означає | Перший крок |
|---|---|---|
| giant graph потрібен переважно для читання списку, картки, експорту | read overfetch, форма даних не збіглася зі сценарієм використання | read-model split + explicit query / fetch-plan |
| giant graph спливає майже в кожній зміні, бо root тягне непов’язані колекції та каскади | занадто широка aggregate / lifecycle boundary | переглянути, що справді має жити й змінюватися в одному unit of work |
Ця думка не скасовує DTO та explicit queries. Вона просто не дає лікувати будь-яку проблему одним і тим самим fetch-трюком: іноді потрібно звузити читання, а іноді — чесно визнати, що write-модель взяла на себе забагато.
3. Явні запити: репозиторій за use case
Є стадія зрілості проєкту, на якій findById() і findAll() перестають бути зручними і починають бути небезпечними. Не тому, що вони погані, а тому, що вони занадто універсальні. Універсальний метод не може знати ваш use case. А Hibernate не вміє читати думки розробника — інколи, щоправда, складається враження, що намагається, але виходить… як виходить.
Явний запит — це коли repository/query-метод називається і реалізується під конкретний сценарій, і в цьому ж місці зафіксовані важливі рішення: форма вибірки, потрібні join-и, потрібний fetch-plan, іноді lock-режим або сортування. Тобто: «Я читаю або змінюю ось це і ось так», а не «ну я дістану entity, а далі побачимо».
У Commerce Persistence Lab дуже типовий приклад — завантаження замовлення для редагування. Якщо ви робите orderRepository.findById(id) і далі в сервісі звертаєтеся до order.getItems(), ви знову повертаєтеся до ситуації, де fetch визначається тим, хто викликав гетер. Це дорога до сюрпризів.
Приклад: метод репозиторію «для редагування» з явним fetch-планом
Ми можемо зробити метод з @EntityGraph (або JPQL join fetch, якщо вам так простіше читати). Тут сенс не в тому, який саме інструмент ви оберете, а в тому, що метод існує під use case.
package com.example.commerce.orders.repository;
import com.example.commerce.orders.entity.PurchaseOrder;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface OrderRepository extends JpaRepository<PurchaseOrder, Long> {
// Для сценарію "редагування" фіксуємо, що потрібні customer і items, а не "потім десь викличуть гетер".
@EntityGraph(attributePaths = {"customer", "items"})
Optional<PurchaseOrder> findForEditingById(Long id);
}
Що дає такий метод?
Він робить межу завантаження даних явною. Якщо сценарій використання «редагування» вимагає клієнта й позиції, це видно з коду репозиторію, а не заховано в «десь там викликали гетер».
Він зменшує ризик N+1. Ми вже знаємо, що EAGER не завжди дає один запит, а LAZY може перетворитися на вторинні select-и. Тут ви фіксуєте: «У цьому сценарії я хочу завантажити ось це».
Він покращує code review. Метод findForEditingById() сам по собі змушує запитати: «А чому для редагування потрібні items?» — і це добре. З findById() таке питання зазвичай не виникає, бо «це ж просто find».
Приклад: query-репозиторій для summary замість entity
Іноді вам потрібне не ціле замовлення, а summary: номер і email клієнта. Це класичний read-model split, але я додам його сюди як приклад explicit query: метод явно виражає форму даних і join.
package com.example.commerce.orders.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;
public interface OrderQueryRepository extends Repository<PurchaseOrder, Long> {
// Важливо: повертаємо не entity, а DTO-зведення, щоб читання було передбачуваним і "вузьким".
@Query("""
select new com.example.commerce.orders.dto.OrderSummary(o.id, o.orderNumber, c.email)
from PurchaseOrder o
join o.customer c
where o.id = :id
""")
OrderSummary findSummaryById(@Param("id") Long id);
}
І сам DTO:
package com.example.commerce.orders.dto;
// Зведення по замовленню для читання: мінімум даних, щоб не тягнути граф PurchaseOrder назовні.
public record OrderSummary(Long id, String orderNumber, String customerEmail) {
}
Тут важливо те, що метод називається не «findSomething», а відображає конкретний read-use case. У зрілому коді це виглядає нудно, але працює як дорожня розмітка: ви рідше помиляєтеся, бо менше «домовляєте» поведінку в себе в голові.
«Явний запит» — це ще й про намір
Дуже частий анти-патерн у persistence layer — коли код виглядає як читання, але робить запис, або навпаки. Наприклад, метод повертає List<PurchaseOrder>, але по дорозі змінює статус або чіпає зв’язки, і ви отримуєте flush у неочікуваний момент. Явний запит допомагає відокремлювати читання від запису, бо для read-методів простіше тримати дисципліну: read-only транзакція, DTO, фіксована форма даних.
Невелика схема: «універсально» vs «явно»
flowchart TD
S["Сервіс"] --> R1["findById"]
R1 -->|entity| S
S -->|десь пізніше| L["Ліниве звернення"]
L --> Q["SQL-сюрпризи"]
S2["Сервіс"] --> R2["findForEditingById"]
R2 -->|entity with planned fetch| S2
S2 --> OK["Передбачувана SQL-межа"]
Якщо чесно, найбільший плюс explicit queries у тому, що вони знімають із вас потребу «пам’ятати все». Ви перестаєте бути людиною-кешем для того, які зв’язки де потрібні. Код сам про це говорить.
4. Smaller unit of work: звужуємо транзакцію
Довга транзакція в Hibernate-світі рідко є просто «довгою транзакцією». Зазвичай це ще й великий persistence context, більше роботи dirty checking, більше шансів отримати flush «не в той момент», більше шансів надовго схопити блокування і більше ризику випадково зробити зайвий SQL, бо хтось усередині транзакції «всього лише» викликав size() у колекції. У підсумку ви отримуєте код, який начебто коректний, але за поведінкою нагадує кімнату з котом і пакетом: ніби тихо, але це не означає, що безпечно.
Патерн smaller unit of work означає, що транзакція має охоплювати лише одну пов’язану business-операцію, а не «операцію плюс звіт, плюс логування, плюс форматування відповіді». Найпрактичніший спосіб навчитися цьому — шукати місця, де всередині @Transactional відбувається робота, що не потребує транзакції: побудова рядків, сортування, групування, серіалізація, підготовка «красивої відповіді».
У Commerce Persistence Lab хороший приклад — закриття замовлень і генерація звіту. Наївно це виглядає так: ми відкрили транзакцію, пробіглися по списку id, змінили статус і відразу ж збираємо рядковий звіт. Формально все працює. Але транзакція живе довше, ніж потрібно, а persistence context тримає managed-сутності, поки ви форматуєте текст. Якщо в процесі звіту ви випадково зачепите щось lazy — отримаєте додаткові запити. Якщо список великий — отримаєте зайве навантаження на dirty checking.
Правильний рефакторинг: транзакція робить лише mutation і повертає мінімальний результат, безпечний поза транзакцією. А форматування звіту живе зовні, де Hibernate вже «не бере участі».
Приклад: command-сервіс закриває замовлення і повертає номери
package com.example.commerce.orders.service;
import com.example.commerce.orders.entity.OrderStatus;
import com.example.commerce.orders.entity.PurchaseOrder;
import com.example.commerce.orders.repository.OrderRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class OrderCloseCommandService {
private final OrderRepository orderRepository;
public OrderCloseCommandService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Transactional // Тут живе лише мутація: змінюємо статус і одразу виходимо з транзакції.
public List<String> closeOrders(List<Long> ids) {
return orderRepository.findAllById(ids).stream()
.peek(o -> o.setStatus(OrderStatus.CLOSED)) // Побічний ефект навмисний: оновлюємо managed-сутність у межах UoW.
.map(PurchaseOrder::getOrderNumber) // Повертаємо просте значення, безпечне поза транзакцією.
.toList();
}
}
Сенс у тому, що ми повертаємо прості значення, які вже є в PurchaseOrder і не потребують lazy-доступу. Ми не повертаємо List<PurchaseOrder> назовні, тому що це знову відчинить двері entity leakage і випадковим підвантаженням.
Приклад: окремий сервіс будує звіт уже поза транзакцією
package com.example.commerce.orders.service;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class OrderCloseReportService {
private final OrderCloseCommandService commandService;
public OrderCloseReportService(OrderCloseCommandService commandService) {
this.commandService = commandService;
}
public String closeAndBuildReport(List<Long> ids) {
List<String> numbers = commandService.closeOrders(ids); // Транзакція завершується всередині commandService.
return String.join("\n", numbers); // Форматування робимо зовні: Hibernate тут уже "не бере участі".
}
}
Ось це і є smaller unit of work у найприкладнішому вигляді: транзакція закривається одразу після mutation-частини, а все інше робиться без участі Hibernate.
Нюанс, об який часто спотикаються: self-invocation
Іноді розробник намагається зробити «один клас, але два методи»: зовнішній нетранзакційний викликає внутрішній @Transactional. Якщо виклик відбувається всередині того самого класу, транзакція може не застосуватися через проксі-механіку Spring. Ми це вже обговорювали раніше, тому тут просто нагадаю: якщо хочете справді розділити межі, робіть окремий бін, як у прикладі вище, або використовуйте інші явні механізми. Не треба влаштовувати «транзакцію Шредінґера», яка є лише в анотації.
Мінісхема: «широкий» vs «вузький» unit of work
flowchart TD
A["@Transactional метод"] --> B["Читання"]
B --> C["Зміна"]
C --> D["Форматування/логування/експорт"]
D --> E["Commit"]
A2["@Transactional command"] --> C2["Зміна"]
C2 --> E2["Commit"]
E2 --> D2["Форматування поза транзакцією"]
Якщо дивитися на це як на правило, воно звучить трохи нудно, але в реальному проєкті економить години діагностики: у транзакції тримаємо лише те, що справді потребує транзакції.
5. Сценарій «до/після» у Commerce Persistence Lab
Коли refactoring patterns живуть окремо, вони виглядають як три незалежні ідеї. На практиці вони добре працюють у зв’язці, тому що кожна закриває слабке місце іншої. Давайте зберемо невелику історію на знайомому домені замовлень, не перетворюючи її на «перепишемо весь застосунок».
Уявімо use case: «показати адміністратору таблицю замовлень і дати можливість закрити вибрані». У наївній версії можна зробити один сервіс: він завантажує PurchaseOrder списком, віддає назовні entity, UI десь читає customer.email, потім користувач вибирає кілька замовлень, сервіс закриває їх і повертає список закритих entity.
Проблема в тому, що така версія тримається на трьох «авось»: авось ніхто не чіпатиме зайві lazy-зв’язки, авось транзакція не виявиться ширшою, ніж ми думаємо, і авось findById() у циклі не створить проблему на обсязі.
У refactored-версії ми робимо нудно, але безпечно.
Список замовлень віддається як DTO — це read-model split. Репозиторій для списку є query-орієнтованим, і запит явно фіксує join до клієнта рівно для email — це explicit query. Сервіс читання позначений @Transactional(readOnly = true) і повертає список рядків, а не entity.
Закриття замовлень робиться окремим command-сервісом, який у транзакції змінює статус і повертає лише номери закритих замовлень — це smaller unit of work плюс відмова від entity leakage на виході. Якщо потрібно показати результат, окремий шар уже поза транзакцією збирає рядок звіту або DTO для відповіді.
Важливо, що це не вимагає «архітектурної революції». Ми не вводимо нові технології, не змінюємо Hibernate і не переписуємо всю доменну модель. Ми просто робимо межі даних явними: що читаємо, що пишемо, де транзакція починається і де закінчується.
І це майже завжди дає два інженерні ефекти, які приємно відчуваються навіть початківцями.
Перший ефект — SQL стає читабельним. Не «мені здається, запитів менше», а справді менше точок, де запит може з’явитися «сам». Ви читаєте DTO — отже, зайвий SQL не з’явиться через гетер.
Другий ефект — код стає простішим для code review. Коли ви бачите findForEditingById() або findActiveRows(), ви розумієте намір. Коли ви бачите сервіс OrderCloseCommandService, ви розумієте межу мутації. Це дуже «немагічний» стиль, і він чудово поєднується з тим, що Hibernate за природою своєю магічний. Ми ніби врівноважуємо сили: Hibernate робить неявне всередині unit of work, а ми робимо явними сам unit of work і форму даних.
6. Типові помилки під час рефакторингу persistence layer
Помилка №1: замінити giant entity graph на giant DTO і вважати, що стало краще.
Іноді розробник чує «read-model split» і вирішує: «О, зробимо один DTO OrderFullDto на 40 полів і житимемо щасливо». Проблема в тому, що ви просто перенесли гігантизм із entity у DTO. Такий DTO почне тягнути join-и, роздувати result set, ускладнювати запити й перетвориться на «універсальний формат на все». Правильніше робити кілька вузьких read-моделей: окрему для списку, окрему для картки, окрему для експорту. Це нудно, але стабільно.
Помилка №2: зробити DTO, але залишити сервіс, який повертає entity «про всяк випадок».
Часто буває так: хтось додав OrderSummary, але старий метод getOrder() усе одно повертає PurchaseOrder, бо «ну іноді комусь треба». У результаті entity leakage лишається, а DTO не стає стандартним шляхом. Важливо домовитися в проєкті: read-сервіси повертають read-моделі, а entity залишаються всередині persistence/write-шару. Якщо «іноді треба», це зазвичай означає: «у нас два різні use cases, які ми випадково змішали».
Помилка №3: намагатися «звузити unit of work», але залишити всередині транзакції форматування, сортування та «красиві» мапінги.
Звуження транзакції — це не про те, щоб поставити анотацію @Transactional на інший метод і заспокоїтися. Це про те, щоб винести з транзакції все, що не потребує транзакції. Якщо всередині @Transactional ви будуєте JSON, CSV, великі рядки, робите складні групування, транзакція все одно широка. Hibernate все одно живе довго. Краще повертати з транзакції мінімальні дані (ID, номери, суми), а «красу» будувати зовні.
Помилка №4: писати явні запити, але не відображати use case у назві методу.
Можна зробити @Query і назвати метод findSomethingCool(). Формально це explicit query, але за відчуттям — знову магія. Хороша назва методу — це теж частина передбачуваності. findForEditingById(), findActiveRows(), findSummaryById() звучать трохи багатослівно, але вони пояснюють intent і допомагають не використовувати метод не за призначенням.
Помилка №5: лагодити тільки код, ігноруючи SQL trace та тести.
Persistence layer підступний: після рефакторингу тести можуть лишатися зеленими, а SQL-поведінка — все ще проблемною. Наприклад, запитів стало менше, але join став ширшим, або зник N+1, але з’явився accidental update. Тому після кожного refactoring move важливо хоча б раз подивитися на SQL trace: скільки запитів, який shape, чи немає зайвих UPDATE. Ми не влаштовуємо performance-аудит на кожен коміт, але «подивитися очима» — це базова гігієна.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ