1. Структура пакетів як архітектура
Коли в проєкті всього три класи, можна дозволити собі легкий хаос: покласти все поруч, назвати пакет «як вийде» і не думати про наслідки. Але саме в цей момент ви закладаєте звичку, яка згодом раптово обертається на запитання: «А чому в нас сервіс із HTTP-анотаціями, а репозиторій — усередині контролера?». Структура пакетів — це не про красу в IDE. Це про те, як мозок читає код, як Spring знаходить ваші біни і як ви утримуєте межі між шарами, поки проєкт дорослішає.
У навчальних проєктах особливо підступна ілюзія: «поки маленьке — неважливо». Насправді саме в маленькому проєкті найлегше домовитися про правила і дотримуватися їх. Якщо ви зараз зафіксуєте зрозумілі зони відповідальності, то далі кожна нова тема курсу, навіть без «магії», ляже в уже готовий каркас. І навпаки: якщо каркас не зафіксувати, то будь-який наступний клас намагатиметься оселитися «кудись» — зазвичай поруч із тим, хто його написав. Це природна урбаністика: якщо не планувати місто, воно виросте само, але паркування у вас будуть на деревах.
Є ще один практичний момент: Spring Boot сканує компоненти за пакетами. Тобто те, куди ви поклали клас, впливає не лише на естетику, а й на те, чи взагалі працюватиме застосунок без ручного налаштування. Тому пакетна структура — це одночасно і архітектура, і частина налаштування застосунку.
2. Базовий пакет com.example.tasktracker
У Java пакет — це не просто «папка з файлами». Це адреса у вашому проєкті, частина імені класу і водночас спосіб домовитися про межі. У Spring Boot є додатковий нюанс: від пакета, де лежить ваш клас застосунку з @SpringBootApplication, зазвичай починається сканування компонентів. Тобто Spring «дивиться» вниз по дереву пакетів і шукає @RestController, @Service, @Repository та інші компоненти. Тому базовий пакет потрібно вибирати свідомо й зафіксувати один раз — щоб потім не ловити загадкові ситуації, коли контролер є, а ендпоїнта немає.
У нашому курсі базовий пакет зафіксовано: com.example.tasktracker. Це просте, але дуже корисне рішення. Воно робить проєкт передбачуваним: усі ваші класи лежать усередині одного кореня, Spring бачить їх без додаткових танців, а ви завжди розумієте, де центр міста, а де вже райони. І так, example — тому що це навчальний проєкт. У реальному продукті там буде домен вашої компанії, але принцип той самий.
Мінімальний орієнтир — головний клас застосунку:
package com.example.tasktracker;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
// Точка входу застосунку: від пакета цього класу зазвичай починається сканування компонентів у Spring Boot.
@SpringBootApplication
public class TaskTrackerApplication {
public static void main(String[] args) {
// Запускаємо Spring-контекст і сам застосунок.
SpringApplication.run(TaskTrackerApplication.class, args);
}
}
Коли цей клас живе в com.example.tasktracker, то контролер у com.example.tasktracker.api.controller буде знайдений автоматично. А якщо ви раптом покладете TaskController у пакет com.example.controllers, Spring його, найімовірніше, не побачить, якщо ви спеціально не налаштували scanning. Тому правило просте: усе, що належить застосунку, має жити під com.example.tasktracker.
Щоб було простіше сприймати структуру, корисно тримати в голові ось таку «карту» пакетів — як дерево каталогів:
# Базова структура пакетів (каркас проєкту)
com.example.tasktracker
├── api
│ └── controller
├── domain
│ ├── model
│ └── service
├── infrastructure
│ ├── repository
│ │ └── inmemory
│ └── storage
└── config
Це ще не всі майбутні пакети проєкту, але для сьогоднішньої лекції — саме той каркас, який нам потрібен, щоб код не розповзався.
3. Зони проєкту: api, domain, infrastructure, config
Коли ви чуєте слово «архітектура», є ризик уявити величезну діаграму на пʼять екранів і людей, які сперечаються про неї до втрати свідомості. Іноді це одна й та сама людина. Нам так не треба. Нам потрібна проста й практична модель, яка допомагає відповісти на базове запитання: куди покласти новий клас і від кого він може залежати? Для цього ми ділимо проєкт на чотири зони відповідальності: api, domain, infrastructure і config. Це не релігія і не «єдино правильний шлях», а нормальний навчальний каркас.
Сенс поділу дуже прикладний. api — це все, що стосується вхідних і вихідних HTTP-запитів. domain — внутрішня модель і прикладні операції, які не повинні знати про транспорт (HTTP) і деталі зберігання (у якій Map лежать задачі). infrastructure — деталі зберігання та технічні реалізації: in-memory-репозиторії, storage-абстракції. config — технічна «проводка»: біни, конфігураційні класи, налаштування, які не є предметною логікою.
Зручно зафіксувати це в таблиці — вона замінює сотню правил на словах:
| Зона | Типові пакети | Що там живе | Чого там бути не повинно |
|---|---|---|---|
| api | api.controller | контролери, HTTP-вхід, анотації Spring MVC | зберігання даних, бізнес-правила «як треба», робота з колекціями як із БД |
| domain | domain.model, |
внутрішня модель, прикладні операції, контракти (інтерфейси) | @RestController, @RequestParam, HTTP-статуси, знання про Map у репозиторії |
| infrastructure | infrastructure.repository.inmemory, |
реалізація зберігання (in-memory), технічні деталі | правила рівня «що можна користувачеві», HTTP-відповіді, бізнес-рішення |
| config | config | @Configuration, створення бінів, технічні налаштування | логіка «створити задачу», «змінити статус», «валідувати заголовок» |
Якщо хочеться візуалізації, то наш каркас можна намалювати так:
flowchart TD
API[api: контролери] --> DOMAIN[domain: модель + сервіси]
DOMAIN -->|через інтерфейси| INFRA[infrastructure: репозиторії/реалізації сховища]
CONFIG[config] --> API
CONFIG --> DOMAIN
CONFIG --> INFRA
Тут важливо вловити думку: контролери ведуть у домен, домен використовує інфраструктуру, найчастіше через інтерфейси, а config не «командує» бізнесом, а просто допомагає зібрати застосунок.
4. Пакет api.controller: що всередині
Контролери — це як ресепшен в офісі. На ресепшені можна запитати, куди пройти, оформити перепустку і зрозуміти, хто ви та навіщо прийшли. Але якщо на ресепшені починають вести бухгалтерію, ремонтувати кавомашину і писати квартальний звіт, офіс перетворюється на реаліті-шоу. Так само і api.controller: це місце, де ми приймаємо HTTP-запит, витягуємо з нього дані з path, query або body, а потім делегуємо роботу в сервісний шар. Контролер відповідає за веб-частину, а не за те, щоб «зберігати задачі в ArrayList».
У нашому каркасі контролери живуть у пакеті com.example.tasktracker.api.controller. У цьому пакеті нормально бачити @RestController, @RequestMapping, @GetMapping та інших друзів. Тут же нормально бачити веб-типи й механізми Spring MVC. Але тут не повинно бути колекцій, які «тимчасово побудуть базою даних», бо тимчасове в IT — це зазвичай назавжди, лише з гіркою усмішкою.
Мінімальний приклад тонкого контролера, який залежить від сервісу:
package com.example.tasktracker.api.controller;
import com.example.tasktracker.domain.service.TaskService;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
// Контролер відповідає лише за HTTP-вхід і делегування в доменний шар.
@RestController
@RequestMapping("/api/v1/tasks") // Базовий шлях для ендпоїнтів задач.
public class TaskController {
private final TaskService taskService; // Залежність на доменний сервіс, а не на репозиторій.
// Передавання залежності через конструктор: простіше тестувати і простіше читати.
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
}
Зверніть увагу: контролер нічого не знає про те, як саме зберігаються задачі. Він знає лише, що є TaskService, який уміє виконувати прикладні операції. Це ключовий принцип архітектури: контролер не має бути «центром всесвіту», інакше він швидко стане найбільшим класом проєкту і, мабуть, найстрашнішим.
5. Пакет domain.model: домен без Spring MVC
Доменна модель — це внутрішній світ вашого застосунку. Це те, як ви уявляєте сутності та їхній стан усередині сервісу, а не те, як вони виглядають зовні в HTTP. Навіть якщо зараз здається, що «ну ми ж просто віддаємо JSON, навіщо ці складнощі», корисно заздалегідь відокремлювати внутрішню модель від веб-шару. Це робить проєкт стійким: ви зможете змінювати контролери й формат відповіді, не переписуючи ядро, і навпаки — зможете поліпшити доменну модель, не ламаючи публічний шар.
Тому Task та інші доменні класи живуть у com.example.tasktracker.domain.model. Усередині domain.model не повинно бути анотацій Spring MVC, не повинно бути знання про ResponseEntity, і загалом домен має бути максимально «лише Java». Це не означає «без Spring взагалі», але означає «без веб-специфіки».
Мінімальний приклад доменної моделі задачі:
package com.example.tasktracker.domain.model;
// Доменна сутність: жодних веб-анотацій і жодного знання про HTTP.
public class Task {
private final String id; // Ідентифікатор зазвичай незмінний.
private String title; // Назву можна змінювати (наприклад, під час операції "перейменування").
public Task(String id, String title) {
this.id = id;
this.title = title;
}
}
Так, це поки що дуже простий клас. І це нормально: сьогодні нам важлива не багата модель, а правильне місце, де вона живе. Зʼявиться більше полів — ви додасте їх сюди. Але ключовий принцип уже має бути зафіксований: доменна модель не лежить поруч із контролером «бо так швидше», інакше завтра ви почнете тягнути в неї веб-деталі, і вона перестане бути доменною.
Ще одна корисна думка: домен — це місце, де зручно тримати контракти прикладних операцій. У нашому курсі це буде пакет domain.service. Але деталі сервісних інтерфейсів та їхньої ролі ми розберемо в наступній лекції; зараз достатньо зрозуміти межу: domain — це «серце» застосунку, і воно не повинно знати, через які двері (HTTP) до нього прийшли.
6. infrastructure: деталі зберігання
Інфраструктурний шар часто сприймають як щось «другосортне»: мовляв, справжня логіка в домені, а інфраструктура — це нудні деталі. Але насправді інфраструктура — це місце, де ви чесно визнаєте: так, дані десь зберігаються; так, є файли; так, є колекції; так, є технічні обмеження. У навчальному проєкті ці деталі будуть простішими, ніж у продакшні, але архітектурно їх усе одно потрібно винести в окрему зону, щоб не заражати домен і контролери.
У нашому проєкті in-memory-репозиторії живуть у пакеті com.example.tasktracker.infrastructure.repository.inmemory. Тут доречні Java-колекції, Map, ініціалізація структури зберігання, а також @Repository, щоб Spring бачив цей клас як компонент, придатний для впровадження залежності.
Скелет in-memory-репозиторію може виглядати так:
package com.example.tasktracker.infrastructure.repository.inmemory;
import org.springframework.stereotype.Repository;
import java.util.LinkedHashMap;
import java.util.Map;
@Repository // Робимо клас Spring-компонентом рівня "зберігання/доступу до даних".
public class InMemoryTaskRepository {
// Просте сховище в памʼяті. LinkedHashMap корисний тим, що зберігає порядок додавання.
private final Map<String, Object> tasks = new LinkedHashMap<>();
}
Цей приклад навмисно спрощений: ми поки не обговорюємо інтерфейси репозиторіїв і конкретні методи. Це тема наступної лекції. Але тут уже видно головне: колекція знаходиться в репозиторії, а не в контролері. Контролер не має бути «мінісховищем», інакше ви змішаєте веб-вхід і зберігання, і проєкт перестане бути зрозумілим.
Ще один важливий принцип: інфраструктура часто залежить від домену, тому що реалізує контракти, які домен визначив. Це нормально. Ненормально, коли домен залежить від конкретної in-memory-реалізації, тому що тоді ви більше ніколи не зможете замінити «колекції» на щось інше без переписування ядра.
7. config: технічні біни
Конфігурація — це те місце, де живуть технічні рішення: які біни створити, яку реалізацію обрати, які залежності склеїти. У новачків часто виникає спокуса зробити config «пакетом для всього незрозумілого» або, навпаки, розпихати конфігурацію по домену, щоб було ближче до логіки. Обидва підходи зазвичай закінчуються тим, що в бізнес-коді зʼявляються технічні деталі, а технічний код починає вирішувати бізнес-питання. Це якби електрик у квартирі вирішував, де у вас буде кухня, тому що «проводам так зручніше».
У нашому каркасі конфігураційні класи живуть у com.example.tasktracker.config. Тут доречні @Configuration і @Bean. І навіть якщо зараз здається, що «нам не потрібно жодного біна вручну», корисно заздалегідь зафіксувати місце, куди такі речі складатимуться, щоб потім не розмазувати їх по проєкту.
Приклад простого технічного біна — системний годинник. Це ніби дрібниця, але дуже швидко стає корисним, коли ви хочете, щоб час брали не «з повітря», а з одного місця:
package com.example.tasktracker.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Clock;
@Configuration // Технічна конфігурація: тут "проводка", а не предметна логіка.
public class TimeConfig {
@Bean
public Clock clock() {
// Єдине джерело часу, яке зручно підміняти в тестах.
return Clock.systemUTC();
}
}
Зверніть увагу, чого тут немає: тут немає методів «створити задачу» або «отримати список задач». Конфігурація не повинна розвʼязувати прикладні задачі. Її роль — допомогти застосунку зібратися, як конструктор, а не стати окремим сервісним шаром «тому що зручно».
8. Напрям залежностей між шарами
Коли ми говоримо «залежність» у Java, це не філософія. Це дуже конкретна річ: import, тип у параметрі методу, тип у полі класу, анотація з чужого шару. Тобто залежність — це коли один клас буквально не може скомпілюватися без іншого. Тому напрям залежностей — це головний спосіб утримати архітектуру в руках: ви просто не дозволяєте шарам «переплутатися місцями».
Базовий напрям викликів і залежностей у нашому проєкті має бути простим і читабельним: controller -> service -> repository/storage. Контролер знає сервіс. Сервіс знає репозиторій, зазвичай через інтерфейс. Репозиторій знає, як зберігати дані в памʼяті. І при цьому доменна модель та сервіси не повинні раптом «знати про HTTP», бо тоді домен стає частиною веб-шару.
Щоб це закріпити, корисно дивитися на проєкт як на систему обмежень. Наприклад, так:
flowchart LR
C[api.controller] --> S[domain.service]
S --> R[infrastructure.repository.inmemory]
S --> ST[infrastructure.storage]
А тепер — що вважається «червоним прапорцем». Якщо ви бачите в domain.model import із Spring MVC, це майже завжди помилка архітектури:
package com.example.tasktracker.domain.model;
import org.springframework.web.bind.annotation.GetMapping; // Так не повинно бути: домен не має знати про веб-анотації.
public class Task {
// Навіть "нешкідливий" import із Spring MVC тягне домен у бік веб-шару.
}
Чому це погано? Тому що домен перестає бути «ядром застосунку» і перетворюється на продовження веб-шару. У результаті будь-які зміни веб-частини будуть протягуватися в домен, а ви втратите сенс поділу на шари.
Ще один практичний спосіб швидко перевірити напрям залежностей — проста матриця «хто від кого може залежати»:
| Хто залежить \ Від кого | |
|
|
|
|---|---|---|---|---|
| api | (рідко) | ✅ так | ⚠️ краще не напряму | ✅ іноді |
| domain | ❌ ні | ✅ так | ❌ ні (від реалізацій) | ⚠️ небажано |
| infrastructure | ❌ ні | ✅ так | ✅ так | ⚠️ іноді |
| config | ✅ так | ✅ так | ✅ так | ✅ так |
Тут найважливіші дві стрічки: domain не залежить від api, і domain не залежить від конкретної інфраструктури. Це і є той самий каркас, який робить застосунок придатним до подальшого розвитку, навіть якщо зараз ви зберігаєте все в памʼяті.
9. Типові помилки під час роботи зі шарами
Помилка №1: «Складемо все в один пакет, а потім розберемося».
Майже ніколи не розбираються. Зазвичай виходить так: ви додаєте другий контролер, поруч зʼявляється сервіс, поруч репозиторій, потім ще один, а через тиждень проєкт перетворюється на лотерею «знайди потрібний клас за назвою». Набагато дешевше одразу домовитися про каркас: api / domain / infrastructure / config.
Помилка №2: пакет util як комірка «для всього, що не влізло».
util — це як домашня шухляда «різне»: спочатку зручно, потім там живе весь ваш побут, включно з пультом від телевізора 2012 року і інструкціями до мікрохвильовки. Якщо ви не можете назвати пакет точніше, ніж util, це зазвичай сигнал, що відповідальність класу не визначена. У навчальному проєкті краще витратити дві хвилини на точну назву і зберегти ясність.
Помилка №3: доменна модель починає залежати від веб-шару.
Щойно в domain.model зʼявляється @RequestParam, @JsonProperty або ResponseEntity, архітектура вже «поїхала». Домен має бути внутрішнім світом застосунку. Він може жити з простими Java-типами, але не повинен знати, як саме ви приймаєте запити.
Помилка №4: зберігання даних у контролері «тому що швидко».
Сьогодні «швидко», завтра «чому у нас список задач очищується під час кожного запиту», післязавтра «чому тести неможливо написати без запуску всього сервера». Контролер — це вхід, а зберігання — інфраструктура. Навіть якщо це зберігання на Map, воно має жити в репозиторії.
Помилка №5: конфігурація перетворюється на бізнес-логіку.
Іноді в config починають писати методи на кшталт createDefaultTasks() і навіть викликають їх із @Bean. Це виглядає як зручний хак, але дуже швидко змішує технічний шар зі змістом предметної області. config має «збирати застосунок», а не «робити роботу застосунку».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ