1. Каркас feature-блока catalog
Сейчас у вас в проекте уже есть сущности, и это похоже на ситуацию, когда у вас есть кирпичи и цемент, но нет дверей и лестницы: жить можно, но странно. Наша цель в этой лекции — получить простой, читаемый и реально используемый каркас каталога, где доступ к данным оформлен через репозитории, а внешняя точка входа для сценариев — через сервис. Мы не будем усложнять архитектуру, но и не будем «прямо из воздуха» вызывать репозитории где попало.
Начнём с того, что зафиксируем ожидаемую структуру пакетов. Мы работаем в стиле package-by-feature, поэтому всё, что относится к каталогу, живёт рядом: entity, repository, service.
Примерно так (это не код, а «карта местности», чтобы вы не потерялись в папках):
src/main/java/com/example/shopdatajpa/catalog
├── entity
│ ├── Category.java
│ └── Product.java
├── repository
│ ├── CategoryRepository.java
│ └── ProductRepository.java
└── service
└── CatalogService.java
Чтобы было проще держать в голове роли, можно представить цепочку вызова в виде простой схемы. Даже если у нас пока нет полноценного web-слоя, «внешний код» (будь то тест, runner или контроллер) должен обращаться к сервису, а сервис — к репозиториям.
flowchart LR A["Внешний слой
(runner / тест / контроллер)"] --> B["CatalogService"] B --> C["CategoryRepository"] B --> D["ProductRepository"] C --> E["PostgreSQL"] D --> E["PostgreSQL"]
Идея здесь очень приземлённая: когда через неделю вы откроете проект, вы должны быстро понять, где искать логику «создать товар» или «найти категорию». Если эти вещи размазаны по репозиториям, runner-ам и случайным классам, мозг начинает тихо плакать.
2. Репозитории: Category и Product
В каталоге у нас уже есть два готовых файла в catalog.repository:
- CategoryRepository — репозиторий для Category на JpaRepository<Category, Long>;
- ProductRepository — репозиторий для Product на JpaRepository<Product, Long>.
Spring Data поднимает их как готовые beans, и оба интерфейса уже дают базовые операции save, findById, existsById и getReferenceById. Значит, сервис получает ровный и предсказуемый контракт доступа к данным.
Полезная дисциплина на этом шаге очень простая: не добавлять в эти репозитории случайные методы «на вырост». Пока каркас каталога держится на базовом контракте, пусть репозитории остаются короткими и скучными — это как раз хороший признак.
3. CatalogService как вход в сценарии
Если репозиторий отвечает на вопрос «как сходить в базу за сущностью», то сервис отвечает на вопрос «что мы хотим сделать по смыслу». Сервис — это место, где вы пишете код человеческими словами: создать категорию, создать товар, проверить наличие товара, получить товар по id. И именно здесь встречаются несколько репозиториев, если это нужно сценарию. Если вы этого не делаете, то очень быстро «внешний слой» начинает напрямую дергать репозитории, и проект превращается в мешок проводов без схемы.
Создадим класс CatalogService и сделаем его Spring-компонентом через @Service. Внедрим в него два репозитория через конструктор. Это самый понятный стиль для новичка: зависимости видны сразу, и класс невозможно создать «случайно» без нужных компонентов.
package com.example.shopdatajpa.catalog.service;
import com.example.shopdatajpa.catalog.repository.CategoryRepository;
import com.example.shopdatajpa.catalog.repository.ProductRepository;
import org.springframework.stereotype.Service;
@Service
public class CatalogService {
// Репозиторий для операций с категориями (CRUD и т.п.)
private final CategoryRepository categoryRepository;
// Репозиторий для операций с товарами (CRUD и т.п.)
private final ProductRepository productRepository;
// Конструкторное внедрение: зависимости видны явно, проще тестировать и сопровождать.
public CatalogService(CategoryRepository categoryRepository, ProductRepository productRepository) {
this.categoryRepository = categoryRepository;
this.productRepository = productRepository;
}
}
Да, класс пока «пустой». И это нормально: мы сначала собираем каркас, потом наращиваем поведение. Плюс такого подхода в том, что вы можете уже сейчас запустить приложение и убедиться, что контекст поднимается, репозитории находятся, и CatalogService создаётся без ошибок.
4. Минимальные методы сервиса
Теперь самое приятное: мы начнём писать методы, которые реально читаются как сценарии. Важно держать себя в руках и не превращать CatalogService в «бог-сервис» на 200 методов. В учебном проекте это особенно коварно: кажется, что «чем больше методов — тем лучше», но на деле вы просто теряете структуру. Мы начнём с минимального набора, который позволит жить каталогу, и будем добавлять только то, что можно объяснить прямо сейчас.
Ниже будут небольшие методы. Они намеренно прямолинейные: мы не переучиваем здесь семантику save или findById, а просто собираем из уже понятных repository-вопросов нормальный сервисный API.
Создание категории: createCategory(...)
Сделаем метод, который создаёт категорию по коду и имени. В реальном приложении вы бы ещё валидировали входные данные и, возможно, реагировали на конфликт уникального кода. Но сейчас нам важно другое: как связать сервис и репозиторий так, чтобы запись была явной и читалась как use case.
(Код ниже — фрагмент, который нужно вставить внутрь CatalogService.)
import com.example.shopdatajpa.catalog.entity.Category;
public Category createCategory(String code, String name) {
// Создаём новую сущность и заполняем обязательные поля
Category category = new Category();
category.setCode(code);
category.setName(name);
// save() вернёт сохранённую сущность (обычно уже с заполненным id)
return categoryRepository.save(category);
}
Обратите внимание на одну деталь: мы возвращаем результат save. Для новичка это очень удобно, потому что после сохранения у объекта появится id, и вы можете тут же использовать его в логах или следующих действиях.
Создание товара: createProduct(...)
Для товара чаще всего есть как минимум SKU, имя и цена. Предположим, что Product у вас уже содержит такие поля (и, возможно, статус). Мы сделаем вариант, который принимает sku, name и price, а статус поставим в какое-нибудь адекватное значение по умолчанию, если он есть.
import com.example.shopdatajpa.catalog.entity.Product;
import java.math.BigDecimal;
public Product createProduct(String sku, String name, BigDecimal price) {
// Создаём сущность товара и заполняем базовые поля
Product product = new Product();
product.setSku(sku);
product.setName(name);
product.setPrice(price);
// Если у вас есть обязательный статус — установите его тут явно, чтобы не было "как получится".
// product.setStatus(ProductStatus.ACTIVE);
return productRepository.save(product);
}
Если у вас в Product есть обязательный status (например, ACTIVE/DRAFT), то добавьте product.setStatus(...) — просто держите это решение явным. Сервис должен создавать сущность в корректном состоянии, а не «как получится».
Чтение товара и Optional
Контракт здесь уже знаком: товар может отсутствовать, поэтому сервис честно возвращает Optional, а не null и не внезапное исключение.
import com.example.shopdatajpa.catalog.entity.Product;
import java.util.Optional;
public Optional<Product> findProduct(Long id) {
// Optional явно показывает: товар может не существовать
return productRepository.findById(id);
}
Если дальше по сценарию товар обязателен, сервис может дать второй метод — «верни товар или упади понятной ошибкой».
getProductOrThrow(...)
Обратный вариант нужен, когда сценарий дальше без товара бессмысленен.
import com.example.shopdatajpa.catalog.entity.Product;
public Product getProductOrThrow(Long id) {
// Если товара нет, падаем сразу и понятным способом: сценарий "требует" товар
return productRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Product not found: " + id));
}
Здесь достаточно простого IllegalArgumentException: нам важно сделать отсутствие товара явным.
Проверка существования: productExists(...)
Здесь нужен только булевый ответ. Если вам всё равно понадобится сам товар, лучше сразу идти в findById, а не делать двойной запрос.
public boolean productExists(Long id) {
return productRepository.existsById(id);
}
Полезная привычка: перед existsById проговорить вопрос, который вы реально задаёте базе. Если вопрос звучит как «дай мне товар», значит нужен не existsById, а findById.
Ссылка: getProductReference(...)
Этот метод добавляем не вместо обычного чтения, а как отдельный контракт: сервис возвращает именно ссылку на Product, если сценарий работает через reference.
import com.example.shopdatajpa.catalog.entity.Product;
public Product getProductReference(Long id) {
return productRepository.getReferenceById(id);
}
Для обычного чтения по id рабочим default всё равно остаётся findById.
5. Smoke-проверка при старте
Писать сервис «в вакууме» — это как собирать мебель без проверки: вроде всё красиво, а потом выясняется, что у вас осталось три лишних шурупа, и один из них — несущий. Нам нужна быстрая smoke-проверка, чтобы убедиться, что CatalogService реально создаётся, репозитории работают, и базовые сценарии выполняются. Самый простой способ — добавить ApplicationRunner-бин, который выполнится при старте приложения и сделает одну-две простые операции.
Здесь важно не увлечься: runner — это временная учебная «пробка», а не полноценный интерфейс приложения. Мы используем его только как быстрый способ проверить wiring и первые вызовы репозитория.
Предположим, у вас есть главный класс приложения ShopDataJpaApplication. Добавим туда бин.
import com.example.shopdatajpa.catalog.service.CatalogService;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
@Bean
ApplicationRunner catalogSmoke(CatalogService catalogService) {
return args -> {
// Минимальная smoke-проверка: создаём категорию и убеждаемся, что id появился
var cat = catalogService.createCategory("books", "Books");
System.out.println("category.id=" + cat.getId()); // category.id=1
};
}
Этого уже достаточно, чтобы проверить wiring: сервис создался, репозиторий внедрился, save отработал, id появился. Если хочется потрогать и товарный сценарий, просто расширьте этот же runner ещё парой вызовов.
6. Типичные ошибки при работе с CatalogService
На этом этапе ошибки обычно не про «сложный SQL» и не про «тонкости Hibernate», а про очень человеческие вещи: куда положили класс, что забыли импортировать, и почему вы вдруг решили, что сервис — это просто «прокси над репозиторием». Хорошая новость в том, что эти ошибки лечатся дисциплиной, а не магическими знаниями.
Ошибка №1: репозитории лежат не в feature-пакете, а в случайном общем repository.
Когда репозитории свалены в одну кучу, вы теряете границы. Сегодня у вас два интерфейса и всё кажется терпимым, а через неделю их станет десять, и вы начнёте искать ProductRepository как носок после стирки — «он точно где-то был». Лечится просто: catalog.repository, inventory.repository, ordering.repository — и точка.
Ошибка №2: CatalogService начинает «раздуваться» и превращается в место для всего.
Очень легко начать добавлять туда «и ещё один метод… и ещё один…». В итоге сервис перестаёт быть «языком сценариев каталога» и становится складом случайных операций. Лечится постоянной проверкой: каждый новый метод должен звучать как сценарий именно каталога, а не всего приложения вообще.
Ошибка №3: злоупотребление связкой existsById + findById.
Новички часто делают «безопасный» двухшаговый сценарий: сначала проверить наличие, потом читать. В результате база получает два запроса вместо одного, а код становится длиннее без пользы. Если вам нужна сущность — используйте findById. Если нужен булевый ответ — используйте existsById. Смешивать эти вопросы обычно не надо.
Ошибка №4: findById(...).get() без проверки.
Optional — это не «коробка, из которой всегда можно достать значение», а честная модель отсутствия. Если вы вызываете .get() без проверки, вы просто откладываете ошибку на более неожиданное место. Если товар обязателен — используйте orElseThrow и сделайте падение понятным и контролируемым.
Ошибка №5: сервис возвращает null, хотя уже есть Optional.
Возврат null в Java — это как «я оставлю тут банановую кожуру, вы просто аккуратнее ходите». Кто-нибудь обязательно поскользнётся. Если метод может не найти сущность, пусть это будет Optional. Так ваш код сам подсказывает, что нужно обработать отсутствие результата.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ