JavaRush /Курсы /Spring Data JPA /Первый CatalogService

Первый CatalogService

Spring Data JPA
7 уровень , 4 лекция
Открыта

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. Так ваш код сам подсказывает, что нужно обработать отсутствие результата.

1
Задача
Spring Data JPA, 7 уровень, 4 лекция
Недоступна
Первый `CatalogService` для создания категории и поиска товара
Первый `CatalogService` для создания категории и поиска товара
1
Задача
Spring Data JPA, 7 уровень, 4 лекция
Недоступна
Первый `CatalogService` с проверкой существования и загрузкой по `id`
Первый `CatalogService` с проверкой существования и загрузкой по `id`
1
Опрос
JPA Репозитории, 7 уровень, 4 лекция
Недоступен
JPA Репозитории
Работа с данными в Spring
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ