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. Коли ви завжди робите обидва кроки, ви платите за страх зайвим кодом і зайвим зверненням до бази.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ