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) — это не «красота», а способ сделать код самодокументируемым: вы видите операцию и сразу понимаете, почему у неё такая транзакция.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ