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[Repository]
    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, а репозитории остаются рядом как access-layer.

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-use-case.
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("Price must be >= 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("Quantity must be >= 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("Delta must be > 0");
        }

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

Здесь особенно хорошо видно read/write split: findStock() ничего не меняет и честно остаётся read-only, а оба метода изменения количества оформлены как write-сценарии с проверками рядом с записью.

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

6. Репозитории: что в них держать

Репозитории на фоне сервисов должны оставаться скучными, и это хорошо. Их задача — дать удобные query-методы и базовый CRUD, а не забирать себе orchestration бизнес-операции.

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> {

    // Метод чтения: удобный query по 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> {

    // Чтение по бизнес-коду: остаётся data-access, не бизнес-операция
    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> {

    // Удобное чтение по business-key (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", "Book", new BigDecimal("9.99")
        ));
    }
}

Внешний слой просто вызывает сценарий сервиса; transaction boundary остаётся внутри сервиса. На такой структуре уже легко наращивать более составные операции без того, чтобы возвращаться к мышлению “каждый 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
Задача
Spring Data JPA, 17 уровень, 4 лекция
Недоступна
CatalogService в feature-пакете
CatalogService в feature-пакете
1
Задача
Spring Data JPA, 17 уровень, 4 лекция
Недоступна
InventoryService в feature-пакете
InventoryService в feature-пакете
1
Опрос
Транзакции сервиса, 17 уровень, 4 лекция
Недоступен
Транзакции сервиса
Сервисы и транзакции
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ