1. Пакети як «карта місцевості» проєкту
Пакети в Java часто сприймають як щось косметичне: «ну просто папки, щоб файли не заважали». Але в реальному бекенд-проєкті пакет — це майже як вулиця і номер будинку: за адресою ви розумієте, де живе код і навіщо він вам потрібен. Добра структура допомагає орієнтуватися без читання кожного файла.
Уявіть, що у вас квартира-студія, і в ній одночасно кухня, спальня, комірчина, офіс і спортзал. Технічно жити можна. Але шукати шкарпетки серед каструль і гантелей — задоволення сумнівне. Плоский Java-проєкт «в одному пакеті» — приблизно те саме: класи фізично існують, але навігація й розуміння ролей починають розвалюватися вже на 15–20 файлах.
У Spring Boot це ще важливіше, ніж у просто Java, тому що пакети впливають не лише на зручність читання, а й на те, що взагалі побачить контейнер. Тобто структура — це не тільки естетика, а й частина механіки застосунку. А ще це дисципліна для мозку: коли ви обираєте пакет, ви змушені відповісти собі на просте запитання: «Цей код про що? Про предметну область? Про інфраструктуру? Про діагностику?»
І тут виникає ключова думка дня: пакети мають допомагати пояснити проєкт людині, яка відкрила репозиторій уперше. Бажано, щоб цією людиною могли бути ви самі через два тижні, тому що памʼять у програміста, як у браузера: кеш протухає, а баги залишаються.
2. Верхній пакет і component scanning
Зараз буде важливий момент, який ламає звичку «ну я покладу main-клас будь-куди, він же просто запускає». У Spring Boot main-клас — це не просто public static void main(...). Він задає точку входу до застосунку, тому що @SpringBootApplication вмикає component scanning, і сканування починається з пакета, де лежить цей клас.
Простіше кажучи, Boot дивиться на пакет com.example.catalogservice і каже: «Гаразд, я шукатиму компоненти всередині цього пакета та в усіх підпакетах». Тому головний клас вигідно тримати максимально високо — у кореневому пакеті застосунку, щоб він «бачив» усе, що потрібно.
Ось добрий, «правильний за замовчуванням» варіант:
package com.example.catalogservice;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
// Головна точка входу: від пакета цього класу Spring Boot запускає component scanning
@SpringBootApplication
public class CatalogServiceApplication {
public static void main(String[] args) {
// Запускаємо Spring-контекст і весь життєвий цикл застосунку
SpringApplication.run(CatalogServiceApplication.class, args);
}
}
І ось типова пастка новачка: ви кладете main-клас, наприклад, у com.example.catalogservice.config, тому що «це ж конфігурація застосунку». А потім раптово виявляється, що com.example.catalogservice.catalog.* уже не сканується, і ваші @Service/@Repository просто не створюються. І ви починаєте підозрювати змову, ретроградний Меркурій і Kotlin — хоча причина банальна: ви самі обрізали область видимості сканування.
Дуже зручно тримати в голові таку картинку (не про внутрішності Spring, а про здоровий глузд):
flowchart TD
A["com.example.catalogservice — головний клас CatalogServiceApplication"] --> B["config"]
A --> C["catalog"]
A --> D["support"]
A --> E["actuator"]
C --> C1["домен"]
C --> C2["репозиторій"]
C --> C3["сервіс"]
C --> C4["web"]
C --> C5["bootstrap"]
Сканування йде вниз по дереву. Тому дерево потрібно будувати так, щоб корінь був там, де ви очікуєте побачити весь проєкт.
3. Гібрид feature і infrastructure
Коли ви вперше намагаєтесь зробити красиво, виникає спокуса піти в крайність. Хтось будує структуру за шарами (controller/service/repository), хтось — за фічами (catalog, orders, users), а хтось обирає третю крайність — пакет util, куди складає все, що не знайшло дому. У нашому курсі ми обираємо гібридний, дуже практичний варіант.
Підхід «за шарами» (web, service, repository) добрий, коли проєкт зовсім маленький і у вас одна предметна область. Але щойно з’являється друга фіча, ви отримуєте довгий список контролерів в одному пакеті та довгий список сервісів в іншому, і вже за структурою неясно, які класи пов’язані між собою.
Підхід «за фічами» (кожна фіча живе окремим блоком і всередині неї є свої web/service/repository) чудово масштабується, але для навчального проєкту на старті може виглядати як надмірне ускладнення: «навіщо мені модульність, якщо у мене один домен?»
Тому для catalog-service ми робимо так: одна основна feature-зона — пакет catalog, а поруч із ним — інфраструктурні пакети, які не належать до конкретної бізнес-фічі, але належать до застосунку як до платформи: config, support, actuator. Так проєкт читається дуже природно: «ось предметна область, а ось обв’язка навколо неї».
І так, це ще й педагогічно чесно. Ми не вдаємо, що будуємо гігантський ентерпрайз, але й не робимо купу класів в одному місці. Ми будуємо структуру, яка витримає зростання курсу, а не розвалиться на десятий день, коли з’явиться web-шар.
4. Структура catalog-service
Щоб структура не була абстрактною ідеєю, її потрібно зафіксувати як очікувану карту проєкту. У цьому курсі карта не має бути ідеальною на всі часи, але має бути достатньо стабільною, щоб ви за нею орієнтувалися до кінця навчання. Тому ми заздалегідь задаємо цільове дерево пакетів і поступово наповнюємо його класами.
Ось той самий «скелет», до якого ми прагнемо (Java-пакети та класи):
com.example.catalogservice ├── CatalogServiceApplication ├── config/ ├── catalog/ │ ├── domain/ │ ├── repository/ │ ├── service/ │ ├── bootstrap/ │ └── web/ ├── actuator/ └── support/
Пакет catalog.web ми створюємо вже зараз як місце під вхідний web-адаптер. Це ще не розмова про MVC-механіку, а просто структурна межа: контролери потім не опиняться де доведеться.
І окремо важливо пам’ятати: у проєкті є не лише src/main/java, а й src/main/resources. У resources знаходяться не Java-пакети, а файли ресурсів: конфігурація, статичні сторінки, зображення тощо. Коли ви побачите src/main/resources/static, це буде не «пакет static», а папка для статичних web-ресурсів. Ми поки туди не ліземо, але вже зараз корисно не плутати ці два світи.
Щоб закріпити, хто за що відповідає, зручно мати невелику таблицю. Вона не замінює читання коду, але допомагає швидко згадати роль кожного блока:
| Пакет | Це про… | Приклади того, що там живе |
|---|---|---|
| catalog | предметну область «каталог курсів» | доменні моделі, репозиторій, сервіси, bootstrap-код каталогу |
| config | явну Java-конфігурацію застосунку | @Configuration класи, @Bean методи |
| support | невеликі інфраструктурні помічники | нормалізатори, маленькі утиліти із чіткою роллю |
| actuator | діагностику і health-check-логіку | індикатори здоров’я та інші діагностичні механізми |
| resources/static | статичні ресурси для web | index.html та все інше, що віддається як файли |
Сенс у тому, щоб ви могли відкрити проєкт і відразу зрозуміти: «Гаразд, бізнес-логіка ось тут, конфігурація ось там, а все інше — допоміжні зони».
5. Feature-зона catalog
Пакет catalog — це наш «світ предметної області». Він не зобов’язаний бути ідеальним DDD-раєм, але він зобов’язаний бути зрозумілим: тут лежить усе, що належить до каталогу курсів, а не до того, як саме Spring Boot це запускає. Ми свідомо тримаємо catalog окремим блоком, щоб не змішувати домен та інфраструктуру.
Всередині catalog ми заздалегідь ділимо код за ролями, тому що ролі у класів різні. Моделі (domain) описують дані та поняття. Репозиторій (repository) відповідає за доступ до даних (у нашому проєкті — in-memory, без бази). Сервіс (service) тримає прикладні дії та правила. Bootstrap (bootstrap) — це стартові штуки, пов’язані з каталогом: ініціалізація, початкове наповнення, звіт про старт. Web (web) — це місце під вхідний web-адаптер, щоб контролери жили поруч із каталогом, а не спливали у випадкових пакетах.
Давайте подивимося, як виглядає «нормальний» доменний клас у правильному пакеті. Він узагалі може не знати про Spring — і це плюс, а не мінус:
package com.example.catalogservice.catalog.domain;
public class CourseCard {
// Доменне поле: ідентифікатор курсу в URL/маршрутах (поки що це просто демонстрація)
private final String slug;
public CourseCard(String slug) {
// У доменній моделі пізніше можна додати інваріанти та перевірки; поки фіксуємо структуру
this.slug = slug;
}
}
Тут важлива не «бідність моделі» (ми розширимо її пізніше), а те, що за шляхом файла і за пакетом видно: це доменна модель каталогу, а не конфігурація і не контролер.
Тепер приклад сервісу. Він уже Spring-компонент, тому позначений @Service, і, як ми домовилися в минулій лекції, залежності приходять через конструктор:
package com.example.catalogservice.catalog.service;
import com.example.catalogservice.catalog.repository.CourseCatalogRepository;
import org.springframework.stereotype.Service;
@Service
public class CourseCatalogService {
// Сервіс спирається на репозиторій, щоб отримувати дані каталогу
private final CourseCatalogRepository repository;
public CourseCatalogService(CourseCatalogRepository repository) {
// Конструкторна інʼєкція: залежність обовʼязкова і явно видима
this.repository = repository;
}
}
Зверніть увагу, як читається структура: catalog.service залежить від catalog.repository. Навіть не знаючи деталей, ви вже можете здогадатися, що сервіс отримуватиме курси з репозиторію та виконуватиме прикладні дії.
І ось мінімальний приклад інтерфейсу репозиторію — теж не заради логіки, а щоб показати його місце в структурі:
package com.example.catalogservice.catalog.repository;
import com.example.catalogservice.catalog.domain.CourseCard;
import java.util.List;
public interface CourseCatalogRepository {
// Контракт репозиторію: повертаємо всі картки курсів (реалізація зʼявиться пізніше)
List<CourseCard> findAll();
}
Поки ми не розбираємо повноцінний web-шар і контракт API, тому catalog.web може бути порожнім, і це нормально. Важливо, щоб місце під вхідний адаптер уже було, і щоб контролери потім не складалися «куди вийде». Звичка «куди вийде» — головний постачальник майбутніх рефакторингів.
6. Інфраструктурні пакети
Тепер подивімося на те, що знаходиться поруч із catalog у корені застосунку. Це інфраструктурні зони. Вони відповідають не за те, «які у нас курси», а за те, «як улаштований застосунок», «як він стартує», «як його розширювати й діагностувати». Їх зручно тримати окремо, щоб домен не потонув в обв’язці.
Пакет config — це місце для Java-конфігурації. Туди ми складаємо класи з @Configuration, щоб не перетворювати проєкт на звалище анотацій по всьому коду. І так, конфігурація може бути порожньою на старті, але вже сам факт, що вона живе в config, дисциплінує.
Ось мінімальний приклад конфігураційного класу (зараз він нічого не робить, але показує місце та роль):
package com.example.catalogservice.config;
import org.springframework.context.annotation.Configuration;
@Configuration
public class StartupConfiguration {
// Тут пізніше зʼявляться @Bean-методи і налаштування wiringʼу, якщо вони знадобляться
}
Пакет support — це дуже корисна і дуже небезпечна зона водночас. Корисна, тому що туди можна складати маленькі технічні помічники, які не належать до однієї конкретної фічі. Небезпечна, тому що туди дуже легко скинути все підряд і отримати той самий util, тільки під іншою назвою. Тому правило просте: support живе лише тоді, коли ви можете пояснити роль кожного класу людськими словами.
Наприклад, ось такий маленький помічник має сенс покласти в support, тому що це не бізнес-логіка каталогу, а технічна нормалізація рядка:
package com.example.catalogservice.support;
public class CatalogSlugNormalizer {
public String normalize(String slug) {
// Найпростіша нормалізація: прибираємо пробіли по краях і приводимо до нижнього регістру
return slug.trim().toLowerCase();
}
}
Пакет actuator ми створюємо заздалегідь, щоб потім не шукати дім для діагностичного коду. Там житимуть штуки, пов’язані зі спостережуваністю застосунку (health, діагностика). Зараз він може бути порожнім — це нормально.
І нарешті, окремо про static. У Spring Boot static — це папка в resources, зазвичай src/main/resources/static. Вона призначена для статичних файлів, які Boot може віддавати як web-ресурси. Це не Java-пакет. Тому не потрібно створювати com.example.catalogservice.static і складати туди класи — це виглядатиме так, ніби ви переплутали шафу з холодильником. І обидва потім пахнуть дивно.
7. Імена пакетів без сорому
Імена пакетів — це маленькі рішення, які накопичуються у великий ефект. Якщо пакет називається добре, він пояснює роль. Якщо пакет називається погано, він маскує проблему: ви не знаєте, куди класти код, і тому називаєте пакет як попало. Найчастіші «як попало» — це util, common, misc, stuff, helpers. Це не імена, це визнання у розгубленості.
Уявіть, що вам у магазині сказали: «Ваше замовлення лежить у відділі “Різне”». Ви йдете до “Різного”, а там батарейки, штори, корм для котів і набір для вишивання. Приблизно так виглядає пакет util: спочатку він здається зручним, потім у ньому неможливо нічого знайти, а ще гірше — неможливо зрозуміти, що можна туди додавати, а що ні.
Краще називати пакет за сенсом відповідальності. Якщо клас нормалізує slug — це support або навіть support.text (якщо справді знадобиться підгрупа). Якщо клас належить до конфігурації старту — config. Якщо клас про каталог курсів — catalog.*.
Невелика табличка «погана назва → чесніша альтернатива» зазвичай швидко лікує око:
| Погана назва пакета | Чому погано | Більш здорова альтернатива |
|---|---|---|
| util | «усе підряд», немає межі | support, support.text, support.logging (якщо роль зрозуміла) |
| common | загальне сміття без власника | конкретний owner: catalog, config, actuator |
| misc | “miscellaneous” = «мені лінь думати» | назвати за відповідальністю класу |
| services у корені | змішує фічі в одну купу | catalog.service (прив’язка до фічі) |
| models у корені | неясно, моделі чого | catalog.domain |
Ще один практичний нюанс: не робіть глибину пакетів заради глибини. Пакет com.example.catalogservice.catalog.domain.money.currency виглядає так, ніби ви будуєте місто, де до найближчого магазину треба їхати автобусом. Спочатку хай структура буде плоскою та зрозумілою, а ускладнення з’являється лише тоді, коли справді виникає другий-третій клас однієї теми.
8. Рефакторинг пакетів без болю
Перенесення класів по пакетах звучить страшніше, ніж є насправді. Найнеприємніше в цьому процесі — не логіка, а акуратність: потрібно, щоб package ...; у файлі збігався з реальним шляхом, щоб імпортів не залишилося битих, і щоб Spring, як і раніше, бачив компоненти через component scan. Якщо діяти спокійно, все проходить досить механічно.
Зазвичай я мислю так: спочатку фіксую кореневий пакет, де живе CatalogServiceApplication (він залишається в com.example.catalogservice). Потім створюю пакети другого рівня: catalog, config, support, actuator. Після цього всередині catalog створюю підпакети domain, repository, service, bootstrap, web. І лише потім розкладаю класи по місцях — не «як вийде», а за роллю.
Під час перенесення класів краще користуватися рефакторингом IDE (наприклад, в IntelliJ IDEA це “Refactor → Move”), тому що IDE одночасно виправляє package-рядок і оновлює імпорти в інших файлах. Якщо робити перенесення вручну через файлову систему, ви майже гарантовано отримаєте хвилин 10 полювання на червоні підкреслення, і в якийсь момент почнете ненавидіти людство. А IDE, на відміну від людства, зазвичай працює передбачувано.
І ще один важливий момент: якщо ви бачите, що клас незрозуміло куди класти, це не привід створювати misc. Це привід чесно запитати: «Яка у класу роль?» Іноді відповідь буде такою: «Клас занадто розмитий і робить усе одразу». І це вже сигнал до рефакторингу дизайну, а не до вигадування нового сміттєвого пакета.
9. Типові помилки під час проєктування структури пакетів
Структура пакетів здається нудною темою рівно доти, доки ви не відкриєте проєкт, де все лежить упереміш. Тоді раптово розумієш, що це не нудьга, а питання виживання. Нижче — типові помилки, які найчастіше роблять початківці, і чому вони справді заважають жити.
Помилка №1: «Один великий пакет, потім розберуся».
Спочатку здається, що так швидше: все в com.example.catalogservice, а там уже подивимося. Але «подивимося» зазвичай не настає, і за тиждень ви не пам’ятаєте, який клас за що відповідає. Лікується просто: виділяйте хоча б catalog і config відразу, а далі додавайте підпакети в міру появи ролей.
Помилка №2: main-клас у занадто глибокому пакеті.
Якщо CatalogServiceApplication поїхав у config або в catalog, component scan починає бачити лише частину проєкту. Підсумок — загадкові помилки “bean not found”, хоча клас начебто позначений @Service. Тримайте main-клас у кореневому пакеті застосунку, а всі інші пакети робіть його підпакетами.
Помилка №3: пакет util як “скринька для всього”.
util не має меж: туди можна покласти що завгодно, і тому туди кладуть усе. Потім ніхто не знає, чи можна від нього залежати, і що взагалі означає “util”. Якщо вже потрібен спільний шар, робіть його маленьким і чесним: support, і всередині — лише класи, роль яких ви можете пояснити одним реченням.
Помилка №4: змішування предметної області та конфігурації.
Коли доменні моделі лежать поруч із класами @Configuration, проєкт перестає читатися. Читач не розуміє, де бізнес-сенс, а де інфраструктура. Хороша структура спеціально розводить ці речі: catalog.* — про каталог, config.* — про wiring/налаштування.
Помилка №5: плутанина Java-пакетів і resources/static.
Новачки іноді створюють Java-пакет static і кладуть туди класи, тому що «я бачив static у проєкті». Але static у Boot — це папка ресурсів, а не Java-код. Домовтеся із собою: Java живе в src/main/java, статичні файли — у src/main/resources/static, і це різні всесвіти.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ