JavaRush /Курсы /Spring Data JPA /Repository contract: repo vs service

Repository contract: repo vs service

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

1. Repository contract: смысл и роль

Когда вы впервые видите Spring Data JPA, очень легко подумать: «О, класс! Теперь я просто добавлю в репозиторий методы на все случаи жизни, и всё будет хорошо». И примерно на этом месте репозиторий начинает превращаться в универсальный комбайн: и читаем, и пишем, и проверяем правила, и “почти бизнес-логика”, и чуть-чуть “а давайте прямо тут склеим два действия”.

Проблема в том, что репозиторий — это не «место для кода про данные», а контракт доступа к данным. Контракт — слово скучное, но очень полезное: это обещание «вот так с данными можно работать». Если контракт размывается, то сервисы начинают зависеть от случайных деталей, код становится трудно читать, а изменения превращаются в игру «угадай, что ещё сломается».

Представьте, что репозиторий — это розетка. Розетка должна быть предсказуемой: 220V и два контакта (условно). Если розетка внезапно начинает быть ещё и выключателем света, и Wi‑Fi роутером, и чайником — то, конечно, “удобно”, но только до первого ремонта. В архитектуре проекта репозиторий должен оставаться розеткой: подключаемся к данным понятным образом и не пытаемся запихнуть туда весь дом.

2. Repository contract как обещание

Если говорить максимально по‑человечески, repository contract — это договор между вашим приложением и слоем хранения: какие операции чтения/записи разрешены, в какой форме они доступны, и с какой сущностью (или частью модели) они работают. Контракт хорош тем, что по нему можно понять намерение: что именно этот репозиторий «обслуживает» и какие вопросы к базе он умеет задавать.

Важно не перепутать контракт с реализацией. Мы как разработчики обычно видим только интерфейс:

package com.example.shopdatajpa.catalog.repository;

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

// Контракт репозитория: фиксируем, с какой сущностью работаем и какой тип у @Id.
public interface CategoryRepository extends JpaRepository<Category, Long> {
    // Тут нет реализации — только обещание доступа к данным.
}

Тут нет ни строчки «как читать из базы». Но контракт уже есть: “я работаю с Category, и идентификатор у неё Long”. И вот это — ключ: контракт задаёт поверхность использования, а не внутренности.

Чтобы не звучало абстрактно, можно посмотреть на контракт как на ответ на три вопроса:

Вопрос Что фиксирует контракт
С чем работаем? Category, Product и т.д. (тип сущности)
Как идентифицируем? Long (тип @Id)
Что обещаем сервису? Набор операций доступа к данным, которые сервис может вызывать

И здесь очень важная мысль: контракт должен быть цельным и связным. Если в одном интерфейсе перемешаны операции каталога, заказов и остатков — это не контракт, а «пакет “всё включено”», который обычно заканчивается тем, что никто не понимает, где что искать.

3. Граница repository и service

Граница между repository и service проще всего объясняется так: репозиторий отвечает на вопрос «как добраться до данных», а сервис отвечает на вопрос «что мы делаем по сценарию». Репозиторий — это инструмент, сервис — это человек, который этим инструментом пользуется по инструкции. И если инструменты начинают сами “решать”, как выполнять сценарий, инструкция разваливается.

Репозиторий в нашем курсе — это слой data-access. Он должен оставаться максимально «тупым» в хорошем смысле слова: он знает, как сохранить, прочитать, удалить, проверить существование. Он не обязан понимать бизнес-смысл “почему именно так”, не должен связывать несколько действий в одну историю, и точно не должен решать, что делать пользователю дальше.

Сервис же — это слой use case. Именно здесь мы обычно держим такие вещи, как последовательность шагов, согласование нескольких репозиториев, простые проверки входных данных, “в каком порядке мы делаем действия” и “какой результат в итоге нужен”.

Сравнение можно зафиксировать в небольшой таблице, но важно читать её не как закон природы, а как практическую «карту территории»:

Что это repository service
Уровень доступ к данным сценарий (use case)
Главный вопрос «как получить/сохранить?» «что сделать в домене?»
Знает про несколько сущностей? обычно нет (или очень ограниченно) да, может координировать
Содержит правила сценария? нет да
В идеале читается как… «операции хранения» «язык предметной области»

И вот здесь рождается важное правило: если вы не можете объяснить метод репозитория как “операцию хранения”, скорее всего, он не должен быть в репозитории.

Например, метод createCategoryAndRenameProduct(...) звучит как сценарий (два действия, два объекта, один “поток”). Это не “операция хранения” — это orchestration. Её место — в сервисе.

4. Пример границы: mini-shop и пакеты

Когда студенты слышат «граница ответственности», первая реакция часто такая: «Ну окей, в теории понял. А где именно это “где” в коде?» Отличный вопрос, потому что границы существуют не только в голове, но и в файловой структуре проекта. Если структура пакетов хаотичная, границы постоянно нарушаются просто потому, что “так удобнее”.

В нашем проекте мы придерживаемся package-by-feature. То есть каталог — отдельно, заказы — отдельно, остатки — отдельно. Внутри каждого feature есть свои сущности, репозитории и сервисы. Получается “маленькая вертикаль” на каждую область. Это помогает держать ответственность на поводке и не давать проекту превратиться в один гигантский пакет repository.

Примерно так это выглядит на уровне пакетов:

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

В такой структуре очень легко задать себе проверочный вопрос: “если я пишу код про категории и товары, почему он лежит в ordering или в common?” Обычно это сигнал, что граница размывается.

А вот схема вызовов, которая отражает здоровую модель:

flowchart TD
    %% Сервис координирует работу нескольких репозиториев.
    S[CatalogService] --> CR[CategoryRepository]
    S --> PR[ProductRepository]
    %% Репозитории — это точка контакта с базой данных.
    CR --> DB[("PostgreSQL")]
    PR --> DB

Если у вас есть веб-слой, он обычно будет над сервисом, но сегодня нам достаточно зафиксировать, что сервис — это место, где встречаются несколько репозиториев, а репозиторий — место, где встречаемся с базой.

5. Примеры репозиториев: хорошо и плохо

Лучше всего архитектурные границы видны через контраст: нормальный пример и антипример. Причём антипример полезен не тем, что “фу-фу”, а тем, что его очень легко написать случайно — просто по инерции. Особенно когда хочется “сделать быстрее” и “пусть будет в одном месте”.

Хороший репозиторий: узкий и понятный

Узкий репозиторий в каталоге выглядит почти скучно. И это комплимент:

package com.example.shopdatajpa.catalog.repository;

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

// Узкий контракт: репозиторий отвечает только за доступ к данным по Product.
public interface ProductRepository extends JpaRepository<Product, Long> {
    // Если появятся специфичные запросы — добавляем их тут, но держим фокус на Product.
}

По нему сразу видно: это репозиторий продуктов. Он не знает про категории как сценарий, не знает про «создать и сразу активировать», не знает про “а ещё надо логировать”. Он просто предоставляет контракт доступа к данным по продуктам.

Плохой репозиторий: “один на всё приложение” и метод-«сценарий»

Вот антипример из серии «вроде бы удобно, а потом всё плохо»:

package com.example.shopdatajpa.repository;

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

// Антипример: название "ShopRepository" намекает на всё приложение,
// но по факту это JpaRepository<Product, Long>.
public interface ShopRepository extends JpaRepository<Product, Long> {

    // Это не "операция хранения", а сценарий из нескольких шагов и сущностей.
    void createCategoryAndRenameProduct(Long categoryId, Long productId, String newName);
}

Здесь сразу несколько проблем, и они не “академические”.

Во‑первых, репозиторий назван “ShopRepository”, но параметризован Product. Получается когнитивный диссонанс: это репозиторий магазина или репозиторий продукта? Если завтра вы добавите туда CustomerOrder — станет ещё веселее.

Во‑вторых, метод createCategoryAndRenameProduct(...) — это не data-access операция. Это сценарий. Он требует координации двух сущностей, и даже если технически его можно выполнить, он ломает слой ответственности: сервис теряет контроль над шагами сценария, а репозиторий становится местом, где «живёт бизнес».

В‑третьих, такой метод обычно провоцирует следующий шаг: добавить туда ещё один метод “и ещё один… и ещё…”. Так репозиторий превращается в giant interface, где интерфейс становится длиннее доменной модели.

6. Сервис-сценарий и репозиторий-инструмент

Самый надежный способ удерживать границу — проектировать так, чтобы код читался как история. Репозиторий должен читаться как “операции хранения”, сервис — как “сценарии”. И это не философия: это практическая читабельность. Когда через полгода вы откроете код, вы должны понимать не только “что вызывает что”, но и “почему это тут”.

Вот минимальный скелет сервиса каталога, который принимает два репозитория. Обратите внимание: сервис не наследуется от репозитория, не “расширяет” его. Он просто использует его как зависимость.

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 {

    // Сервис хранит зависимости на репозитории: он будет координировать сценарии.
    private final CategoryRepository categoryRepository;
    private final ProductRepository productRepository;

    // Внедрение зависимостей через конструктор — стандартный и прозрачный вариант.
    public CatalogService(CategoryRepository categoryRepository, ProductRepository productRepository) {
        this.categoryRepository = categoryRepository;
        this.productRepository = productRepository;
    }
}

Здесь важна сама форма: сервис — это точка входа в use case. Даже если внутри пока нет логики, архитектурно вы уже сделали правильный шаг: репозиторий — инструмент, сервис — сценарий.

Теперь добавим маленький пример use case-метода, который не делает «умную магию», а просто выражает намерение. Допустим, нам нужно понять, существует ли товар. Репозиторий умеет existsById, а сервис делает метод с доменным именем. Это мелочь, но она улучшает читаемость кода на уровне feature.

import com.example.shopdatajpa.catalog.repository.ProductRepository;
import org.springframework.stereotype.Service;

@Service
public class CatalogService {

    // В этом сервисе нам нужен только репозиторий продуктов.
    private final ProductRepository productRepository;

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

    public boolean isProductPresent(Long productId) {
        // Техническую операцию репозитория мы "упаковываем" в доменное имя use case.
        return productRepository.existsById(productId);
    }
}

Обратите внимание, как меняется смысл: existsById — технический вопрос к хранилищу, а isProductPresent — уже “язык каталога”. Сервис становится местом, где технические операции складываются в доменные намерения.

Когда граница проведена так, первый CatalogService собирается почти механически: репозитории приходят в конструктор как инструменты, а наружу сервис отдаёт уже методы на языке каталога.

7. Правила для репозиториев

Когда вы начинаете писать репозитории, очень хочется сделать их “полезнее” и “умнее”. Иногда это действительно нужно. Но чаще это просто попытка компенсировать отсутствие сервисного слоя. Поэтому полезно держать в голове несколько простых правил — не как догму, а как напоминания, которые экономят часы рефакторинга.

Начните с того, что каждый репозиторий должен иметь чёткий объект внимания. Если у вас CategoryRepository, то он должен звучать как “операции хранения категорий”. Если вы ловите себя на мысли: “а давай сюда добавим метод про продукт, потому что тут удобнее” — это почти всегда признак, что вы потеряли feature‑границу.

Дальше полезно помнить про связность методов. Если вы читаете репозиторий и видите методы, которые не похожи друг на друга по смыслу, репозиторий уже начинает расползаться. Сервис в этот момент обычно выглядит наоборот: у него разные методы и они “про жизнь”, и это нормально, потому что сервис — про сценарии. Репозиторий же — про однотипный доступ к данным.

Отдельная привычка, которая сильно помогает: не пытайтесь «закрыть весь проект одним репозиторием». Даже если кажется, что “у нас же маленький учебный проект”. Маленькие проекты растут быстрее всего — особенно учебные: вы добавляете темы курса, и структура либо выдерживает, либо превращается в кашу. Разделение на CategoryRepository, ProductRepository и т.д. — это не роскошь, а способ не потерять контроль.

И наконец, держите в уме простую проверку: если метод репозитория звучит как “сделай мне кусок бизнес‑операции”, это почти наверняка сервисная работа. Репозиторий может помочь выполнить шаг, но он не должен быть режиссёром спектакля.

8. Типичные ошибки при работе с repository и service

Этот раздел полезен тем, что почти все ошибки здесь выглядят “логично” в момент написания кода. Вы не пытаетесь сделать плохо — вы пытаетесь сделать быстрее. Но Spring Data JPA — штука, которая быстро прощает хаос на старте и строго взыскивает его чуть позже, когда код становится больше.

Ошибка №1: бизнес-логика в репозитории, потому что “это же про базу”.
Часто начинающий разработчик думает: раз операция касается базы, значит она должна быть в репозитории. И туда попадают проверки, правила, “если нет категории — создать”, “если статус такой — запретить” и прочие вещи. В итоге репозиторий перестаёт быть контрактом доступа к данным и превращается в сценарный слой. Правильнее держать правила в сервисе, а репозиторий оставлять инструментом.

Ошибка №2: один гигантский репозиторий на весь проект.
Это выглядит особенно заманчиво в учебном проекте: “у нас же всего несколько сущностей, зачем плодить интерфейсы?”. Но цена платится очень быстро: любой новый use case начинает добавлять туда методы, интерфейс раздувается, и вы теряете чувство структуры. Гораздо легче сопровождать проект, когда репозитории разделены по feature и по сущности: читаешь ProductRepository — думаешь о продуктах, читаешь CustomerOrderRepository — думаешь о заказах.

Ошибка №3: сервис как “прокси без смысла”.
Иногда делают сервис, который просто повторяет методы репозитория один в один: save, findById, existsById с теми же именами, без добавления сценария. Тогда сервис не приносит пользы и начинает раздражать: “зачем этот слой?”. Сервис полезен, когда он формулирует use case и делает код более доменным по смыслу. Даже маленькое переименование в духе isProductPresent вместо existsById уже делает слой полезнее.

Ошибка №4: репозиторий начинает “общаться наружу” (логирование, консоль, форматирование).
Если вы видите желание писать в репозитории System.out.println("Saved!") или “сформировать красивое сообщение об ошибке” — это явный сигнал, что слой ответственности смешался. Репозиторий — про доступ к данным. Сообщения, логирование бизнес-событий и прочая “внешняя жизнь” должны быть выше, в сервисе или ещё выше. Репозиторий должен оставаться тихим и предсказуемым.

Ошибка №5: два шага existsById + findById “на всякий случай”.
Эта ошибка похожа на осторожность, но часто превращается в лишний запрос и усложнение кода. Если вам нужна сущность — задайте один честный вопрос через findById и обработайте Optional. Если нужен только факт существования — используйте existsById. Когда вы всегда делаете оба шага, вы платите за страх лишним кодом и лишним обращением к базе.

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