1. Контролер: тонкий вебрівень поверх service
Коли вже зрозуміло, як HTTP-запит доходить до методу контролера, можна спокійно збирати справжній контролер каталогу. Але дуже легко переплутати ролі й почати писати бізнес-логіку прямо в контролері, бо «ну раз запит уже тут…». На цьому етапі ми свідомо проводимо межу: контролер — це адаптер, а не мозок застосунку.
Якщо спростити до чесної інженерної схеми, у нас є шари, і кожен розвʼязує своє завдання. Контролер розвʼязує завдання «як прочитати запит і що повернути». Сервіс розвʼязує завдання «що вважати правильною відповіддю за правилами застосунку». Репозиторій розвʼязує завдання «звідки взяти дані (у нашому випадку — з памʼяті)».
flowchart TD
A["HTTP-клієнт: браузер/curl/Postman"] --> B["Вбудований Tomcat"]
B --> C[DispatcherServlet]
C --> D[CourseCatalogController]
D --> E[CourseCatalogService]
E --> F[CourseCatalogRepository]
F --> G["(дані в памʼяті)"]
Важливий момент: ми не створюємо контролер через new. Ми не викликаємо його методи вручну. Він живе в Spring-контексті, а викликає його Spring MVC. Тому, якщо ви колись відчуєте бажання зробити new CourseCatalogController(...), зупиніться, вдихніть і згадайте: це вже не консольний застосунок, це «доросла» система, де життям обʼєктів керує контейнер.
І ще одна межа, яку корисно тримати в голові: контролер — це місце, де зʼявляються HTTP-деталі (шляхи, параметри, статуси). Сервіс — це місце, де зʼявляються предметні деталі (курси, featured, published). Змішувати ці два світи можна, але вийде «суп», який складно підтримувати: туди постійно почнуть прилипати нові параметри, перевірки, винятки й «а давайте тут ще один фільтр». Ми саме вчимося робити навпаки.
2. @RestController і тіло відповіді
До цього місця вже видно головне: контролер каталогу має не шукати HTML-сторінки, а віддавати назовні дані сервісу. Тому базова анотація тут — @RestController: вона каже Spring MVC, що значення, яке повертає метод, потрібно писати прямо в тіло HTTP-відповіді.
Якби ми використовували @Controller, то рядковий return за замовчуванням трактувався б як імʼя подання (view). Для catalog-service це не те, що потрібно: ми будуємо JSON-сервіс лише для читання, і вебрівень у нас має повертати дані, а не рендерити сторінки. Точковий варіант із @ResponseBody теж існує, але для такого контролера простіше й чесніше одразу поставити @RestController на клас. З цією опорою можна одразу збирати CourseCatalogController: вебрівень читає HTTP-вхід і віддає назовні дані сервісу.
3. Каркас CourseCatalogController
Тепер ми зберемо головний клас сьогоднішньої лекції — контролер каталогу. Тут важливо не лише «які анотації написати», а й як зберегти читабельність. Найчастіша дрібна проблема новачка: один і той самий префікс URL повторюється в кожному методі, а залежності підтягуються хаотично. Ми зробимо акуратно: загальний префікс винесемо на рівень класу, а залежність уведемо через конструктор.
Домовімося про базовий URL. Ми хочемо, щоб усі кінцеві точки каталогу жили під одним «будинком» — /api/catalog. Тоді маршрути читаються очима: «це API, це каталог, це курси». І якщо завтра зʼявляться ще кінцеві точки, вони не розлетяться по проєкту, як шкарпетки після прання.
(на початку файла буде package com.example.catalogservice.catalog.web;)
import com.example.catalogservice.catalog.service.CourseCatalogService;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController // Контролер, який віддає дані (зазвичай JSON), а не HTML-сторінки
@RequestMapping("/api/catalog") // Загальний префікс для всіх методів цього контролера
public class CourseCatalogController {
private final CourseCatalogService service; // Залежність від бізнес-логіки (service), а не від repository
public CourseCatalogController(CourseCatalogService service) {
this.service = service; // Впровадження через конструктор: залежність обовʼязкова і видима
}
}
Тут одразу кілька важливих речей, які варто проговорити вголос (так, можна й пошепки, якщо ви в офісі).
По-перше, @RestController робить цей клас Spring-біном, а Spring MVC побачить його і зареєструє маршрути. По-друге, @RequestMapping("/api/catalog") — це не «магія», а просто префікс: до нього додаватимуться шляхи методів. По-третє, constructor injection — це наш базовий стиль: залежність CourseCatalogService стає обовʼязковою і видимою. Якщо сервіс не створився, застосунок упаде на старті, а не «десь потім».
І дуже важлива архітектурна звичка: контролер залежить від сервісу, а не від репозиторію. Репозиторій — це вже «внутрішня кухня». Контролер — це «офіціант». Офіціант не повинен заходити на кухню і сам смажити котлети. Інакше він стає не офіціантом, а дуже втомленою людиною.
4. Список курсів: GET /api/catalog/courses
Перша кінцева точка майже завжди найприємніша, бо дає відчуття «це справді працює». Але саме на ній частіше за все роблять шкідливу звичку: починають прямо в контролері фільтрувати, сортувати, обмежувати, і в результаті контролер перетворюється на міні-сервіс. Ми робимо рівно одну річ: приймаємо запит і делегуємо.
У нашому випадку список курсів — це GET /api/catalog/courses. Повертатимемо список CourseCard. На цьому етапі нам не потрібно жодних DTO, жодних «обгорток на кшталт { data: ... }» і тим більше жодних ручних рядків "{\"id\":...}". Просто повертаємо Java-обʼєкти — і MVC сам перетворить їх на JSON через HttpMessageConverter.
Всередині CourseCatalogController додаємо метод:
import com.example.catalogservice.catalog.domain.CourseCard;
import org.springframework.web.bind.annotation.GetMapping;
import java.util.List;
@GetMapping("/courses") // GET /api/catalog/courses (префікс береться з @RequestMapping на класі)
public List<CourseCard> findAll() {
// Контролер не фільтрує і не сортує: він просто делегує в сервіс
return service.findAll();
}
Якщо ви зараз спитаєте: «А де тут JSON?» — відповідь буде чесною: «Усередині Spring MVC і Jackson». Саме в цьому й сенс стартера та підходу Spring Boot: ви пишете бізнес-код, а інфраструктура займається перетворенням форматів.
Щоб було зовсім наочно, уявімо, що service.findAll() повертає два курси. Тоді JSON-відповідь буде виглядати приблизно так (форматування може відрізнятися, але ідея така):
[
{ "id": "spring-boot", "slug": "spring-boot", "title": "Spring Boot" },
{ "id": "spring-core", "slug": "spring-core", "title": "Spring Core" }
]
І ще один важливий нюанс: цей метод не повинен друкувати в консоль, не повинен збирати рядки й не повинен ловити винятки «про всяк випадок». Якщо щось зламається, Boot поверне стандартну помилку. Наша задача зараз — зробити перший чистий вхід з вебрівня до сервісу.
5. Курс за slug і 404
Список — це добре, але справжній каталожний сервіс зазвичай уміє віддавати конкретну сутність за ключем. У нашому випадку ключ — це slug (людинозрозумілий ідентифікатор в URL). І тут зʼявляється важлива практична деталь: що робити, якщо курс не знайдено? Можна, звісно, «повернути null і сподіватися на краще», але краще так не робити — у надії погана статистика в продакшені.
У мінімальному варіанті ми хочемо коректний HTTP-статус 404 Not Found. Ми не проєктуємо повноцінний контракт помилок і не пишемо глобальну обробку винятків, але статус 404 — це базова ввічливість API: «такого ресурсу немає».
Зробімо так: сервіс буде повертати Optional<CourseCard>, а контролер перетворить його на ResponseEntity.
Метод у контролері:
import com.example.catalogservice.catalog.domain.CourseCard;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/courses/{slug}") // GET /api/catalog/courses/{slug}
public ResponseEntity<CourseCard> findBySlug(@PathVariable String slug) {
// Якщо курс знайдено -> 200 OK + JSON-тіло. Якщо ні -> 404 Not Found без тіла.
return service.findBySlug(slug)
.map(ResponseEntity::ok) // Успішна відповідь: статус 200
.orElseGet(() -> ResponseEntity.notFound().build()); // Ресурс не знайдено: статус 404
}
Зверніть увагу на дві речі. Перша: @PathVariable String slug — це звʼязування частини шляху {slug} з аргументом методу. Друга: ми явно говоримо, яку відповідь повернути, якщо курс знайдено (200 OK + тіло), і яку, якщо не знайдено (404 без тіла).
А щоб ця історія працювала, сервіс має вміти повертати Optional. Наприклад, у CourseCatalogService це може виглядати так (це лише ідея, а не «єдина правильна релігія»):
import com.example.catalogservice.catalog.domain.CourseCard;
import java.util.Optional;
public Optional<CourseCard> findBySlug(String slug) {
// Сервіс не «вигадує» HTTP-статуси: він просто повідомляє, знайшли ми курс чи ні
return repository.findBySlug(slug);
}
У підсумку контролер лишається тонким, а рішення «є/немає» лежить там, де йому комфортно: сервіс і репозиторій знають дані, контролер знає HTTP-відповідь.
6. Featured: GET /api/catalog/featured
Тепер додамо маршрут, який добре показує ідею «один сервіс — кілька зрозумілих входів». Featured-підбірка — це не новий тип даних. Це той самий CourseCard, лише відібраний за правилом «featured = true». І важливий момент: правило відбору — це бізнес-логіка каталогу, а не HTTP-деталь. Тому фільтрація має жити в сервісі, а контролер — лише викликати потрібний метод.
Маршрут буде такий: GET /api/catalog/featured. За змістом це «представлення» каталогу: ті курси, які ми хочемо показати як рекомендовані.
Метод контролера:
import com.example.catalogservice.catalog.domain.CourseCard;
import org.springframework.web.bind.annotation.GetMapping;
import java.util.List;
@GetMapping("/featured") // GET /api/catalog/featured
public List<CourseCard> featured() {
// Логіку «що таке featured» тримаємо в сервісі, тут тільки HTTP-вхід
return service.findFeatured();
}
А ось приклад того, як це може бути реалізовано в сервісі (і це якраз демонстрація «логіка в service, а не в controller»):
import com.example.catalogservice.catalog.domain.CourseCard;
import java.util.List;
public List<CourseCard> findFeatured() {
// Фільтрація за бізнес-правилом: featured = true
return repository.findAll().stream()
.filter(CourseCard::featured) // Беремо тільки рекомендовані курси
.toList();
}
Так, це маленький Stream. Так, він читається нормально. І так, це саме те місце, де фільтрація доречна: сервіс відповідає за «що таке featured».
Важливо: ми поки що не обговорюємо, скільки елементів повертати, і звідки береться ліміт. На поточному етапі нам достатньо самої ідеї: окрема кінцева точка, окремий метод сервісу, зрозуміла JSON-відповідь.
7. Серіалізація CourseCard у JSON
Коли ви вперше повертаєте List<CourseCard> із контролера, виникає відчуття, що десь усередині Spring сидить маленький гном і пише JSON вручну. Насправді все простіше й нудніше (а нудне — це зазвичай добре): Spring MVC вибирає відповідний HttpMessageConverter, і для JSON зазвичай використовується Jackson. Jackson дивиться на ваш обʼєкт як на звичайний Java-обʼєкт: поля, getter-и, компоненти record — і перетворює його на JSON.
Щоб побачити це наочно, досить мінімального CourseCard. Наприклад, у доменному пакеті це може бути record (records для «карток» підходять чудово: дані незмінні, структура зрозуміла, зайвих сеттерів немає).
import java.time.LocalDate;
// Доменна модель «картки курсу», яку ми напряму серіалізуємо в JSON
public record CourseCard(
String id, // Внутрішній ідентифікатор курсу
String slug, // Людинозрозумілий ключ для URL (наприклад, spring-boot)
String title, // Назва курсу
boolean featured, // Ознака «рекомендований»
LocalDate launchDate // Дата запуску (зазвичай стане рядком на кшталт "2026-04-01" у JSON)
) { }
Якщо такий обʼєкт повернути з контролера, JSON матиме поля id, slug, title, featured, launchDate. Зазвичай дати серіалізуються в рядок, наприклад "2026-04-01". На цьому етапі ми не налаштовуємо формат, не пишемо кастомні серіалізатори і не додаємо анотації Jackson «про всяк випадок». Наша мета — базова передбачуваність: повертаємо Java-обʼєкт, отримуємо JSON.
Корисно тримати просту відповідну табличку, щоб не дивуватися:
| Java-тип | У що перетворюється в JSON |
|---|---|
| String | рядок |
int, , |
число |
| boolean | true/false |
| List<T> | масив |
| record / POJO | обʼєкт |
| null | null (якщо поле присутнє) |
І ще одна практична рекомендація: на ранньому етапі краще повертати «чисті» обʼєкти даних. Якщо ви почнете повертати щось на кшталт Optional<CourseCard> просто з контролера, воно може серіалізуватися неочікувано (або просто виглядати дивно для клієнта). Тому Optional — чудовий тип усередині сервісів, але назовні (у HTTP) краще віддавати або конкретний обʼєкт, або ResponseEntity.
8. Типові помилки під час роботи з контролерами
Коли ви починаєте писати контролери, помилки зазвичай двох категорій: «не зрозумів, як працює Spring MVC» і «зрозумів, але вирішив зробити по-своєму, бо так швидше». Друга категорія зазвичай наздоганяє болючіше, але перша трапляється частіше, тож почнемо з неї.
Помилка №1: використовувати @Controller і дивуватися, чому не приходить JSON.
Новачок пише метод, повертає List<CourseCard>, запускає, робить запит — і бачить дивну поведінку (або помилку про view). Причина майже завжди в тому, що @Controller без @ResponseBody орієнтований на подання. Для JSON-відповідей у нашому сервісі базова анотація — @RestController, і вона ж робить намір коду очевидним.
Помилка №2: складна логіка всередині контролера «бо тут же все поруч».
Руки самі тягнуться зробити stream().filter(...).sorted(...).limit(...) прямо в кінцевій точці, особливо коли фільтр простенький. Проблема не в стрімі, а в тому, що контролер починає знати про бізнес-правила. Через пару днів туди додасться другий фільтр, потім третій, потім «якщо такий параметр, то ось так», і контролер перетворюється на міні-потвору, яку неможливо читати. Правильна звичка — у контролері лише отримати вхідні параметри і викликати метод сервісу.
Помилка №3: field injection (@Autowired на полі) — тихий спосіб втратити контроль над залежностями.
Field injection здається «коротшим», але робить залежності неявними, погіршує тестованість і легко призводить до ситуацій «воно стартує, але незрозуміло чому». Constructor injection, навпаки, змушує залежності бути чесними. У навчальному проєкті це особливо важливо: ми будуємо не тільки код, а й правильні звички.
Помилка №4: повертати Optional назовні як частину HTTP-контракту.
Optional — чудовий інструмент усередині Java-коду, але у вебконтракті він виглядає дивно: клієнту не потрібно знати, що всередині сервісу ви використали Optional. Клієнту потрібен або ресурс (200), або його відсутність (404). Тому Optional краще «розпаковувати» в контролері і відповідати через ResponseEntity.
Помилка №5: намагатися «зробити красиво» і одразу побудувати DTO-шар, обгортки та універсальні відповіді.
На цьому першому вебрівні це виглядає як «я дорослий розробник», але зазвичай перетворюється на «я щойно написав собі мікрофреймворк поверх Spring». Зараз наша мета — підняти мінімальний, чесний сервіс: GET-маршрути, зрозумілі URL, прості доменні обʼєкти в JSON. Краса тут — у ясності, а не в кількості абстракцій.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ