JavaRush /Курси /Spring Data JPA /Транзакційні сервіси каталогу та залишків

Транзакційні сервіси каталогу та залишків

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

1. Від репозиторію до сервісу

Тепер уже видно і unit of work, і звичайний @Transactional, і readOnly = true, і правило про зовнішній public-вхід до Spring bean. Залишається зібрати все це в один зрозумілий робочий шар для mini-shop: CatalogService та InventoryService, у яких одразу видно, де read, а де write. На такому сервісному шарі згодом легко читати й складніший сценарій оформлення замовлення: кроків стане більше, але сама логіка межі не зміниться.

Репозиторій і далі відповідає на запитання «як сходити до бази та принести або зберегти дані». Сервіс відповідає на запитання «яку бізнес-операцію ми зараз виконуємо і де в неї єдина межа». Цього вже достатньо, щоб більше не сперечатися про ролі шарів: далі ми просто збираємо сервісний шар так, щоб write-сценарії жили під @Transactional, read-сценарії — під @Transactional(readOnly = true), а репозиторії залишалися цеглинками, а не господарями use case.

Для цієї стадії проєкту достатньо одного CatalogService і одного InventoryService.

Невелика схема допомагає швидко втримати межу в голові:

flowchart LR
    A[Зовнішній виклик] --> B["CatalogService / InventoryService"]
    B --> C[Репозиторій]
    C --> D[(PostgreSQL)]

    subgraph TX["@Transactional: межа unit of work"]
      B
      C
    end

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

2. Feature-first: зони відповідальності

Коли проєкт стає трохи більшим за одну сутність, найважливіше — швидко знаходити місце, де живе use case. Тому тримаємо package-by-feature і одразу фіксуємо простий робочий варіант: у catalog.service живе один CatalogService, у inventory.service — один InventoryService. Пізніше такий шар можна дробити далі, але спочатку корисно побачити просту й читабельну форму.

com.example.shopdatajpa
├── catalog
│   ├── entity
│   ├── repository
│   └── service
├── inventory
│   ├── entity
│   ├── repository
│   └── service
└── common
    └── ...

Сенс не в красі дерева каталогів. Сенс у тому, що операції з категоріями та товарами шукаються в CatalogService, операції із залишками — в InventoryService, а репозиторії залишаються поруч як рівень доступу до даних.

3. Публічні методи сервісу: контракт read/write

Тепер у сервісів має зʼявитися зрозумілий публічний API: один публічний метод — одна предметна дія, а анотація одразу показує, це read чи write. Імена на кшталт createProduct, renameCategory, changePrice, setAvailableQuantity тут корисніші за будь-який загальний process() — по них одразу видно, де в операції межа.

Коли параметрів стає багато, краще не перетворювати сигнатуру на випробування для памʼяті. Для створення товару зручно ввести команду через record.

package com.example.shopdatajpa.catalog.service;

import java.math.BigDecimal;

// Команда для створення товару: зручніше, ніж десяток параметрів у методі
public record CreateProductCommand(
        Long categoryId,
        String sku,
        String name,
        BigDecimal price
) {
}

Щоб швидко перевірити, що сервіси спроєктовані зрозуміло, корисно тримати перед очима коротку карту методів:

Метод Тип операції Анотація Ідея контракту
createProduct(cmd) write @Transactional Створити товар як єдиний сценарій.
renameCategory(id, name) write @Transactional Змінити назву категорії без прихованих побічних кроків.
changePrice(id, price) write @Transactional Змінити ціну й тримати перевірку поруч із записом.
findProductsByCategory(id, pageable) read @Transactional(readOnly = true) Отримати сторінку каталогу без прихованого запису.
findStock(productId) read @Transactional(readOnly = true) Отримати залишок без побічних ефектів.
setAvailableQuantity(productId, qty) write @Transactional Встановити залишок як write-сценарій.
addAvailableQuantity(productId, delta) write @Transactional Окремий сценарій поповнення складу.

4. CatalogService: операції та транзакції

Для каталогу на цьому етапі зручніше один CatalogService, у якому поруч живуть і читання, і записи. Так простіше побачити межу use case і не стрибати між кількома дрібними класами завчасно.

package com.example.shopdatajpa.catalog.service;

import com.example.shopdatajpa.catalog.entity.Category;
import com.example.shopdatajpa.catalog.entity.Product;
import com.example.shopdatajpa.catalog.repository.CategoryRepository;
import com.example.shopdatajpa.catalog.repository.ProductRepository;
import java.math.BigDecimal;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class CatalogService {

    private final CategoryRepository categoryRepository;
    private final ProductRepository productRepository;

    public CatalogService(CategoryRepository categoryRepository,
                          ProductRepository productRepository) {
        this.categoryRepository = categoryRepository;
        this.productRepository = productRepository;
    }

    @Transactional
    public Long createProduct(CreateProductCommand cmd) {
        Category category = categoryRepository.findById(cmd.categoryId()).orElseThrow();

        Product product = new Product();
        product.setCategory(category);
        product.setSku(cmd.sku());
        product.setName(cmd.name());
        product.setPrice(cmd.price());

        return productRepository.save(product).getId();
    }

    @Transactional
    public void renameCategory(Long categoryId, String newName) {
        Category category = categoryRepository.findById(categoryId).orElseThrow();
        category.setName(newName);
        categoryRepository.save(category);
    }

    @Transactional
    public void changePrice(Long productId, BigDecimal newPrice) {
        if (newPrice.signum() < 0) {
            throw new IllegalArgumentException("Ціна має бути не меншою за 0");
        }

        Product product = productRepository.findById(productId).orElseThrow();
        product.setPrice(newPrice);
        productRepository.save(product);
    }

    @Transactional(readOnly = true)
    public Page<Product> findProductsByCategory(Long categoryId, Pageable pageable) {
        return productRepository.findByCategoryId(categoryId, pageable);
    }
}

createProduct() тут найпоказовіший: усередині одного public-методу в нас і читання категорії, і створення продукту, і збереження. Саме так unit of work перестає бути абстрактною ідеєю й стає звичайним сервісним методом.

renameCategory() і changePrice() нагадують, що навіть короткий update — це все ще write use case. findProductsByCategory() показує симетричну половину картини: read-сценарій живе в тому ж сервісі, але вже під readOnly. Якщо потрібен простий findProduct(), він лягає сюди за тією ж схемою: public read-метод + @Transactional(readOnly = true).

5. InventoryService: операції та транзакції

Із залишками робимо те саме. Один InventoryService стає точкою входу і для читань, і для змін кількості.

package com.example.shopdatajpa.inventory.service;

import com.example.shopdatajpa.inventory.entity.StockItem;
import com.example.shopdatajpa.inventory.repository.StockItemRepository;
import java.util.Optional;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class InventoryService {

    private final StockItemRepository stockItemRepository;

    public InventoryService(StockItemRepository stockItemRepository) {
        this.stockItemRepository = stockItemRepository;
    }

    @Transactional(readOnly = true)
    public Optional<StockItem> findStock(Long productId) {
        return stockItemRepository.findByProductId(productId);
    }

    @Transactional
    public void setAvailableQuantity(Long productId, int quantity) {
        if (quantity < 0) {
            throw new IllegalArgumentException("Кількість має бути не меншою за 0");
        }

        StockItem stockItem = stockItemRepository.findByProductId(productId).orElseThrow();
        stockItem.setAvailableQuantity(quantity);
        stockItemRepository.save(stockItem);
    }

    @Transactional
    public void addAvailableQuantity(Long productId, int delta) {
        if (delta <= 0) {
            throw new IllegalArgumentException("Зміна має бути більшою за 0");
        }

        StockItem stockItem = stockItemRepository.findByProductId(productId).orElseThrow();
        stockItem.setAvailableQuantity(stockItem.getAvailableQuantity() + delta);
        stockItemRepository.save(stockItem);
    }
}

Тут особливо добре видно розподіл read/write: findStock() нічого не змінює й чесно залишається read-only, а обидва методи зміни кількості оформлені як write-сценарії з перевірками поруч із записом.

Коли use case стане більшим і зачепить і каталог, і залишки одночасно, сама ідея не зміниться: один зовнішній public-метод збиратиме кілька кроків в один unit of work.

6. Репозиторії: що в них тримати

Репозиторії на тлі сервісів мають залишатися нудними, і це добре. Їхнє завдання — дати зручні query-методи та базовий CRUD, а не забирати собі оркестрацію бізнес-операції.

ProductRepository для сторінки каталогу може бути таким:

package com.example.shopdatajpa.catalog.repository;

import com.example.shopdatajpa.catalog.entity.Product;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {

    // Метод читання: зручний запит за categoryId
    Page<Product> findByCategoryId(Long categoryId, Pageable pageable);
}

CategoryRepository часто містить читання за бізнес-кодом:

package com.example.shopdatajpa.catalog.repository;

import com.example.shopdatajpa.catalog.entity.Category;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface CategoryRepository extends JpaRepository<Category, Long> {

    // Читання за бізнес-кодом: це лишається доступом до даних, а не бізнес-операцією
    Optional<Category> findByCode(String code);
}

Для залишків нам потрібен передбачуваний метод читання за productId:

package com.example.shopdatajpa.inventory.repository;

import com.example.shopdatajpa.inventory.entity.StockItem;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface StockItemRepository extends JpaRepository<StockItem, Long> {

    // Зручне читання за бізнес-ключем (productId)
    Optional<StockItem> findByProductId(Long productId);
}

Щойно в репозиторій проситься метод changePrice(...) або setAvailableQuantity(...), це майже завжди сигнал, що use case тягнуть не туди. Репозиторій має допомагати сервісу, а не підміняти його.

7. Карта сервісів і самоперевірка

Тепер сервісний шар має читатися як карта use cases. Якщо ви відкриваєте CatalogService і InventoryService та за назвами методів одразу розумієте, де читання, а де запис, значить шар зібраний правильно.

Сервіс write-методи (@Transactional) read-методи (@Transactional(readOnly = true))
CatalogService createProduct, renameCategory, changePrice findProductsByCategory
InventoryService setAvailableQuantity, addAvailableQuantity findStock

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

package com.example.shopdatajpa;

import com.example.shopdatajpa.catalog.service.CatalogService;
import com.example.shopdatajpa.catalog.service.CreateProductCommand;
import java.math.BigDecimal;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

@Component
public class DemoRunner implements CommandLineRunner {

    private final CatalogService catalogService;

    public DemoRunner(CatalogService catalogService) {
        this.catalogService = catalogService;
    }

    @Override
    public void run(String... args) {
        // Зовнішній шар просто викликає сценарій сервісу; транзакції живуть усередині сервісу
        catalogService.createProduct(new CreateProductCommand(
                1L, "SKU-001", "Книга", new BigDecimal("9.99")
        ));
    }
}

Зовнішній шар просто викликає сценарій сервісу; межа транзакції залишається всередині сервісу. На такій структурі вже легко нарощувати складніші операції без потреби повертатися до мислення «кожен save() живе сам по собі».

8. Типові помилки транзакційних сервісів

Помилка №1: один «бог-сервіс» на весь домен.
Іноді здається, що простіше зробити ShopService, куди покласти і каталог, і залишки, і все інше. На короткій дистанції це справді швидше. На довгій — це перетворюється на моноліт усередині моноліту: методи розростаються, транзакції стають надто широкими, а зрозуміти, де яка відповідальність, дедалі важче. Поділ на CatalogService і InventoryService — це не формальність, а спосіб тримати код читабельним і передбачуваним.

Помилка №2: бізнес-операції починають жити в репозиторії.
Щойно ви ловите себе на думці «додам метод changePrice(...) прямо в ProductRepository, щоб не писати сервіс», ви фактично скасовуєте ідею unit of work на рівні сервісу. Репозиторій перестає бути інструментом доступу до даних і починає бути місцем бізнес-логіки, хоча він гірше підходить для оркестрації. У результаті транзакції ставляться «де доведеться», а не «де треба».

Помилка №3: змішування читання і запису в одному публічному методі без ясного контракту.
Метод, який називається findProducts..., але всередині «по дорозі» оновлює статус товару або змінює залишок, — це пастка. Він ламає очікування: читач думає, що це read, а це раптом write. За місяць такий код стає джерелом дивних побічних ефектів: хтось просто хотів отримати сторінку каталогу, а отримав зміну даних. Розділяйте методи за наміром і фіксуйте це анотацією (readOnly для читання, звичайна транзакція для запису).

Помилка №4: розмита точка входу і надія, що анотація «сама спрацює».
Якщо транзакційний метод викликається зсередини класу (self-invocation) і ви розраховуєте, що проксі Spring «якось здогадається», вас чекає сюрприз: транзакція може не стартувати там, де ви очікували. Правильна звичка — тримати транзакцію на публічному методі сервісу, який реально викликається зовні, а приватні helper-методи використовувати як частину сценарію всередині вже відкритої транзакції.

Помилка №5: надто «технічні» назви методів, які не відображають предметну дію.
Назви на кшталт process(), handle(), doWork() — це майже завжди спроба сховати відсутність ясного контракту. Транзакційна межа стає неочевидною вже на рівні назви: «що за work?». Предметні назви (renameCategory, setAvailableQuantity) — це не «краса», а спосіб зробити код самодокументованим: ви бачите операцію й одразу розумієте, чому в неї саме така транзакція.

1
Опитування
Транзакції сервісу, рівень 17, лекція 4
Недоступний
Транзакції сервісу
Сервіси й транзакції
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ