JavaRush /Курси /Spring Data JPA /Контракт репозиторію: repo vs service

Контракт репозиторію: repo vs service

Spring Data JPA
Рівень 7 , Лекція 3
Відкрита

1. Контракт репозиторію: зміст і роль

Коли ви вперше бачите Spring Data JPA, дуже легко подумати: «О, клас! Тепер я просто додам у репозиторій методи на всі випадки життя, і все буде добре». І приблизно на цьому місці репозиторій починає перетворюватися на універсальний комбайн: і читаємо, і пишемо, і перевіряємо правила, і «майже бізнес-логіка», і трохи «а давайте прямо тут склеїмо дві дії».

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

Уявіть, що репозиторій — це розетка. Вона має бути передбачуваною: 220 В і два контакти, умовно кажучи. Якщо розетка раптом починає бути ще й вимикачем світла, і Wi‑Fi роутером, і чайником — то, звісно, «зручно», але лише до першого ремонту. В архітектурі проєкту репозиторій має залишатися розеткою: підʼєднуємося до даних зрозумілим способом і не намагаємося запхати туди весь дім.

2. Контракт репозиторію як обіцянка

Якщо говорити максимально по-людськи, 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 найпростіше пояснити так: репозиторій відповідає на запитання «як дістатися до даних», а сервіс — на запитання «що ми робимо за сценарієм». Репозиторій — це інструмент, сервіс — це людина, яка користується ним за інструкцією. І якщо інструменти починають самі «вирішувати», як виконувати сценарій, інструкція розвалюється.

Репозиторій у нашому курсі — це шар доступу до даних. У хорошому сенсі він має залишатися максимально «простим»: він знає, як зберегти, прочитати, видалити, перевірити існування. Він не зобов’язаний розуміти бізнес-зміст «чому саме так», не повинен пов’язувати кілька дій в одну історію і точно не має вирішувати, що робити користувачу далі.

Сервіс же — це шар use case. Саме тут ми зазвичай тримаємо такі речі, як послідовність кроків, узгодження кількох репозиторіїв, прості перевірки вхідних даних, «у якому порядку ми робимо дії» і «який результат у підсумку потрібен».

Порівняння можна зафіксувати в невеликій таблиці, але важливо читати її не як закон природи, а як практичну «карту місцевості»:

Що це repository service
Рівень доступ до даних сценарій (use case)
Головне запитання «як отримати або зберегти?» «що зробити в предметній області?»
Знає про кілька сутностей? зазвичай ні, або дуже обмежено так, може координувати
Містить правила сценарію? ні так
В ідеалі читається як… «операції зберігання» «мова предметної області»

І ось тут народжується важливе правило: якщо ви не можете пояснити метод репозиторію як «операцію зберігання», найімовірніше, йому не місце в репозиторії.

Наприклад, метод createCategoryAndRenameProduct(...) звучить як сценарій: дві дії, два об’єкти, один потік. Це не «операція зберігання», а оркестрація. Його місце — у сервісі.

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("Збережено!") або «сформувати гарне повідомлення про помилку» — це явний сигнал, що шар відповідальності змішався. Репозиторій — про доступ до даних. Повідомлення, логування бізнес-подій та інше «зовнішнє життя» мають бути вище, у сервісі або ще вище. Репозиторій має залишатися тихим і передбачуваним.

Помилка №5: два кроки existsById + findById «про всяк випадок».
Ця помилка схожа на обережність, але часто перетворюється на зайвий запит і ускладнення коду. Якщо вам потрібна сутність — поставте одне чесне запитання через findById і обробіть Optional. Якщо потрібен лише факт існування — використовуйте existsById. Коли ви завжди робите обидва кроки, ви платите за страх зайвим кодом і зайвим зверненням до бази.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ