JavaRush /Курси /Spring Data JPA /Бізнес-операція і unit of ...

Бізнес-операція і unit of work

Spring Data JPA
Рівень 17 , Лекція 0
Відкрита

1. Репозиторій — не бізнес-операція

Коли ви тільки починаєте писати бекенд, дуже природно — і дуже небезпечно — думати так: «Ну я ж викликав save() — значить “зберіг товар”». Або: «Я викликав findById() — значить “отримав товар”». Таке мислення працює рівно до першого моменту, коли ви додаєте другий рядок коду — і раптом розумієте, що «зберегти товар» уже означає: перевірити категорію, перевірити унікальність SKU, можливо, оновити повʼязані дані, і лише потім справді писати в базу.

Репозиторій у Spring Data JPA — це зручна обгортка над доступом до даних. Він добре відповідає на запитання рівня «дістань», «збережи», «перевір існування». Але бізнес-операція — це сценарій із початком і кінцем. І якщо ви плутаєте «крок сценарію» зі «сценарієм цілком», ваш код швидко перетворюється на набір випадкових викликів, які складно читати, небезпечно змінювати й неприємно налагоджувати.

Щоб відчути різницю, корисно порівняти два поняття: «технічна дія над даними» і «прикладний намір».

Бізнес-операції mini-shop

Якщо ви подивитеся на наш навчальний домен mini-shop, то побачите, що справжні дії звучать як нормальні людські дієслова. «Створити товар», «перевести товар в іншу категорію», «змінити ціну», «встановити доступну кількість на складі». Це не INSERT і не UPDATE, хоча база, звісно, врешті-решт отримає саме їх. Бізнес-операція — це те, що має сенс пояснити менеджеру, тестувальнику й самому собі через тиждень, не відкриваючи журнали Hibernate.

Важливий момент: бізнес-операція майже завжди має умови «до» і «після». Наприклад, «створити товар у категорії» означає, що категорія має існувати, інакше сама фраза «у категорії» просто не має сенсу. А після операції товар має бути збережений і коректно пов’язаний із категорією. Якщо посередині щось пішло не так, ви не хочете отримати стан «категорію прочитали, обʼєкт товару створили, але в базу не записали» — це не бізнес-результат, це просто «ми почали, але не закінчили».

Спробуємо сказати те саме більш інженерно: бізнес-операція задає інваріанти і межу відповідальності. Інваріанти — це правила: «світ після операції має бути в такому-то стані». Межа відповідальності — це «ось тут операція починається, ось тут закінчується, і все всередині — один смисловий блок».

2. unit of work і межа транзакції

Термін unit of work звучить грізно, ніби зараз буде розділ із книжки на 800 сторінок. Насправді ідея проста: unit of work — це набір повʼязаних дій над даними, які мають завершитися разом як єдине ціле. Не тому, що так «красивіше», а тому, що інакше застосунок може залишити дані в частково оновленому стані, а ви будете довго дивитися в монітор і запитувати себе: «Чому в базі так дивно?».

З погляду коду unit of work — це, найчастіше, один публічний метод сервісу, який виражає прикладну дію. Усередині можуть бути читання через репозиторії, перевірки, створення обʼєктів, зміна полів, збереження. Важлива не кількість рядків, а зміст: якщо ці кроки повʼязані однією метою й одним підсумковим станом — це один unit of work.

З погляду бази даних unit of work зазвичай відповідає тому, що ми називаємо межею транзакції: моменту, коли «операція почалася» і «операція закінчилася». Навіть якщо ви поки не думаєте про конкретні анотації та налаштування, корисно тримати в голові саме цю картину: один смисловий сценарій має мати один логічний «контейнер цілісності».

Поки що це лише смислова рамка. Далі нам залишиться зробити наступний крок: оформити її так, щоб Spring і база даних бачили ту саму межу.

Щоб побачити це візуально, уявімо операцію як ланцюжок кроків:

flowchart TD
    A["Бізнес-операція: створити товар"] --> B["Прочитати категорію"]
    B --> C["Зібрати обʼєкт Product"]
    C --> D["Зберегти Product"]
    D --> E["Результат: товар існує й повʼязаний із категорією"]

Тут важливо, що це одна історія. Репозиторії дають нам цеглинки — кроки, а сервіс збирає з цих цеглинок дім. І бажано так, щоб дім не перетворювався на шалаш від першого ж вітру.

3. Приклади unit of work

createProduct()

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

Почнімо з того, як зазвичай виглядає каркас сервісу каталогу — він у вас уже є або дуже схожий на той, який ви писали раніше:

import org.springframework.stereotype.Service;

@Service
public class CatalogService {
    // Репозиторій для роботи з категоріями: читання, перевірки, збереження
    private final CategoryRepository categoryRepository;
    // Репозиторій для роботи з товарами: створення, пошук, зміна
    private final ProductRepository productRepository;

    // Конструкторна інʼєкція: залежності сервісу оголошено явно
    public CatalogService(CategoryRepository categoryRepository,
                          ProductRepository productRepository) {
        this.categoryRepository = categoryRepository;
        this.productRepository = productRepository;
    }
}

Тепер сама операція створення товару. Зверніть увагу: у ній вже є читання і запис — навіть якщо метод виглядає компактно.

import java.math.BigDecimal;

public Long createProduct(Long categoryId, String sku, String name, BigDecimal price) {
    // Крок 1: читаємо категорію (інваріант: товар створюємо в наявній категорії)
    Category category = categoryRepository.findById(categoryId).orElseThrow();

    // Крок 2: збираємо доменний обʼєкт у памʼяті
    Product product = new Product();
    product.setCategory(category); // повʼязуємо товар із категорією
    product.setSku(sku);           // встановлюємо SKU
    product.setName(name);         // встановлюємо назву
    product.setPrice(price);       // встановлюємо ціну

    // Крок 3: зберігаємо в базу (тут і зʼявляється INSERT)
    productRepository.save(product);

    // Повертаємо ID вже збереженої сутності
    return product.getId();
}

Якщо перекласти цю операцію на «мову бази», у вас вийде приблизно така послідовність SQL-дій (спрощено):

-- 1) читаємо категорію
select * from category where id = :categoryId;

-- 2) пишемо товар
insert into product(...) values (...);

Важливий висновок: бізнес-операція «створити товар» не дорівнює «викликати save». save() — це лише один крок. А сама операція включає щонайменше перевірку існування категорії через читання і сам запис товару.

Отже, практичний висновок такий: якщо ви хочете розуміти код, вам потрібно називати й групувати його за бізнес-операціями. Тому імʼя createProduct() — це не косметика, а частина інженерного контракту: воно говорить, що робить метод, де починається дія і де має бути видимий підсумок.

moveProductToCategory()

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

Ось компактний варіант такої операції:

public void moveProductToCategory(Long productId, Long categoryId) {
    // Крок 1: читаємо сам товар, щоб упевнитися, що він існує
    Product product = productRepository.findById(productId).orElseThrow();
    // Крок 2: читаємо цільову категорію, бо вона теж має існувати
    Category category = categoryRepository.findById(categoryId).orElseThrow();

    // Крок 3: змінюємо звʼязок у доменній моделі
    product.setCategory(category);

    // Крок 4: зберігаємо зміни в базі
    productRepository.save(product);
}

Якщо дивитися на це очима бізнесу, тут є одна дія: «перенести товар». Якщо дивитися очима бази, буде приблизно так:

-- Читаємо товар, який переносимо
select * from product where id = :productId;

-- Читаємо категорію, в яку переносимо
select * from category where id = :categoryId;

-- Оновлюємо звʼязок (FK) у товару
update product set category_id = :categoryId where id = :productId;

І ось тут дуже корисно звикнути мислити не «викликами репозиторію», а «картою операції». У вас є кілька кроків, і вони логічно повʼязані. Вони потрібні, щоб наприкінці вийшов осмислений підсумок: товар має посилатися на нову категорію. Якби ви рознесли це на випадкові методи й почали викликати їх по частинах, ви б швидко отримали сценарії на кшталт «категорію знайшли, а товар не знайшли», «товар знайшли, але категорія неактивна», «товар перенесли, але далі код упав і зовнішня частина системи вважає, що перенесення не відбулося». І це все — не проблеми JPA. Це проблеми організації бізнес-операції.

Оновлення залишків

Залишки (StockItem) — класична зона, де «нібито просто», доки не зʼявляється хоча б одна перевірка. Припустімо, у нас є операція «встановити доступну кількість товару». Уже на цьому рівні вам зазвичай потрібно: знайти запис залишків, перевірити, що кількість не відʼємна, змінити поле, зберегти. І це все — один сценарій.

Ось маленький приклад, який легко читати:

public void setAvailableQuantity(Long productId, int quantity) {
    // Крок 1: читаємо запис залишків за товаром
    StockItem stockItem = stockItemRepository.findByProductId(productId).orElseThrow();

    // Крок 2: бізнес-перевірка (інваріант: кількість не може бути відʼємною)
    if (quantity < 0) {
        // Це не "помилка бази", а помилка вхідних даних операції
        throw new IllegalArgumentException("Кількість має бути >= 0");
    }

    // Крок 3: застосовуємо зміну в доменній моделі
    stockItem.setAvailableQuantity(quantity);

    // Крок 4: зберігаємо новий результат у базі
    stockItemRepository.save(stockItem);
}

Можна сперечатися про те, який тип винятку кращий і куди винести перевірку, і ми ще не раз будемо про це сперечатися — це майже хобі розробників. Але з погляду сьогоднішньої лекції важливіше інше: ця операція — unit of work, тому що вона виражає мету й гарантує підсумковий стан. На вході у нас «хочу встановити кількість», на виході — «у базі справді нова кількість, і вона не відʼємна».

Якщо думати репозиторіями, можна випадково почати писати так: «в одному місці прочитав, в іншому місці змінив, у третьому зберіг». А потім дивуватися, чому код став нелінійним і крихким. unit of work дисциплінує: тримаємо кроки поруч, щоб сценарій було видно цілком.

4. Межа unit of work

Дуже природна помилка після знайомства з unit of work — вирішити, що «раз усе має бути разом, давайте в один метод запхаємо взагалі все». Так робити не треба. unit of work — не виправдання для гігантизму. Це спосіб зробити межу операції видимою, а не спосіб побудувати сервіс-«чорну діру», куди падають усі дії проєкту.

Хороша межа зазвичай читається за дієсловом і за обіцянкою результату. Якщо метод називається createProduct(), то він має зробити саме це: створити товар. Усередині він може читати категорію, валідовувати поля, зберігати, але він не має раптом «заодно» перераховувати звіти, масово оновлювати інші товари й запускати розпродаж — це вже схоже на сюжет поганого фільму, де сценаристу лінь писати нову сцену. Якщо метод називається moveProductToCategory(), то він про перенесення, а не про «а ще перейменувати категорію, якщо вона порожня».

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

Для наочності можна тримати в голові невелику таблицю — не як «правило закону», а як підказку:

Питання до себе Якщо відповідь «так», це ознака unit of work
Чи можна описати дію одним дієсловом? Отже, в операції є мета й межа
Чи є кілька кроків читання/запису, які логічно повʼязані? Отже, кроки треба зібрати в один сценарій
Чи є «не можна залишити частковий результат»? Отже, операція має завершуватися як ціле
Чи можна дати методу імʼя, яке не соромно показати людині? Отже, ви проєктуєте за змістом, а не за технікою

І знову: це не «чек-лист обов’язковості», а спосіб не загубити зміст. Ми не намагаємося перетворити бекенд на релігію. Ми намагаємося зробити код читабельним і передбачуваним.

5. Сервіс і репозиторій: ролі

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

У нашому проєкті CatalogService і InventoryService мають звучати як набір нормальних доменних дій. Навіть без обговорення конкретних транзакційних налаштувань, про які ми поки що говоримо лише на рівні змісту, структура сервісів уже підказує межі unit of work.

Наприклад, такі методи сприймаються як операції, а не як випадкові допоміжні методи:

public void renameCategory(Long categoryId, String newName) {
    // Читаємо категорію: операція має сенс лише для наявної сутності
    Category category = categoryRepository.findById(categoryId).orElseThrow();

    // Змінюємо стан: це і є "rename" на рівні домену
    category.setName(newName);

    // Зберігаємо результат: фіксуємо нове імʼя в базі
    categoryRepository.save(category);
}

Або так:

public void changeProductPrice(Long productId, BigDecimal newPrice) {
    // Читаємо товар, ціну якого змінюємо
    Product product = productRepository.findById(productId).orElseThrow();

    // Застосовуємо зміну в доменній моделі
    product.setPrice(newPrice);

    // Зберігаємо новий стан
    productRepository.save(product);
}

В обох випадках видно одне й те саме: ми читаємо сутність, змінюємо стан, зберігаємо. Це сценарій. Він зрозумілий як єдина дія. І найприємніше: такий код легко розширювати. Хочете додати перевірку newPrice >= 0? Додаєте її поруч. Хочете заборонити змінювати ціну неактивного товару? Перевіряєте статус тут же. unit of work допомагає тримати інваріанти в одному місці, а не шукати їх по проєкту, як загублені шкарпетки після прання.

6. Типові помилки під час роботи з unit of work

Помилка № 1: вважати, що «якщо метод короткий — значить це один крок».
Довжина методу взагалі не гарантує його семантичної простоти. createProduct() може займати 10 рядків, але всередині вже буде читання категорії й запис товару. Якщо ви мислите лише розміром коду, ви пропускаєте ризик частково виконаного сценарію й втрачаєте смислову межу операції.

Помилка № 2: проєктувати API сервісу як набір «технічних» методів замість доменних дій.
Методи на кшталт process(), handle(), doWork() звучать загадково, але погано. Вони не допомагають зрозуміти, що саме є unit of work. Коли сервісні методи називаються предметно (renameCategory(), moveProductToCategory(), setAvailableQuantity()), межа операції стає очевидною навіть без коментарів.

Помилка № 3: розмазувати один сценарій по кількох місцях і потім збирати його «в голові».
Дуже легко почати писати так, що читання робиться в одному методі, перевірка — в іншому, запис — у третьому, а в підсумку «операція» існує лише у вашій уяві. На практиці це призводить до крихкості: ви змінюєте один шматок і не помічаєте, як ламаєте інший. unit of work — це якраз звичка тримати повʼязані кроки поруч.

Помилка № 4: намагатися «сховати бізнес» у репозиторій, бо там же база.
Репозиторій справді ближчий до даних, але це не робить його правильним місцем для оркестрації. Якщо ви починаєте додавати в репозиторій методи на кшталт moveProductToCategory(productId, categoryId) і всередині робите кілька читань і бізнес-перевірок, ви перетворюєте шар доступу до даних на напівбізнесовий шар. Через деякий час репозиторій розростається, і команда перестає розуміти, де закінчується доступ до даних і починається зміст операції.

Помилка № 5: оцінювати необхідність «цілісної операції» за кількістю репозиторіїв.
Іноді операція торкається лише одного репозиторію, але все одно є важливим unit of work. Наприклад, зміна ціни товару може зачіпати лише один ProductRepository, але вона все одно має бути оформлена як зрозуміла доменна дія, тому що у неї є інваріанти та очікуваний підсумок. Кількість репозиторіїв — не критерій. Критерій — зміст і ризик часткового результату.

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