JavaRush /Курси /Spring REST & MVC /Сервісний шар та інтерфейси

Сервісний шар та інтерфейси

Spring REST & MVC
Рівень 9 , Лекція 1
Відкрита

1. Роль сервісного шару

Коли проєкт маленький, дуже хочеться зробити так: контролер отримав запит, узяв Map, дістав задачу, повернув її. І здається, що сервіс — це зайва прокладка. Але щойно ви додаєте створення задач, оновлення, обмеження домену (наприклад, «архівну задачу не можна змінювати») і кілька ресурсів (коментарі, вкладення), контролер перетворюється на «кухню» з пательнями, каструлями та дротами — і ви раптом не розумієте, де в вас бізнес-рішення, а де транспортні деталі.

Сервісний шар у навчальному проєкті потрібен не тому, що «так заведено у дорослих». Він потрібен як центральне місце прикладних операцій. Контролер відповідає за HTTP-частину: як прочитати вхідні дані та як сформувати вихід. Репозиторій відповідає за зберігання. А сервіс відповідає за зміст: що означає «створити задачу», «отримати задачу», «отримати список задач». Саме в сервісі зручно тримати рішення на кшталт генерації ідентифікатора, заповнення полів за замовчуванням і перевірок предметних обмежень. Ідеться не про валідацію вхідних даних анотаціями — до цього ми ще дійдемо.

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

2. Ролі Controller, Service, Repository

Якщо ви лише починаєте, то найчастіша плутанина виглядає так: «Контролер — це і є бекенд». У реальності контролер — це лише web-вхід: він живе на межі, спілкується по HTTP і має бути максимально тонким. Сервіс — це прикладний шар: він описує операції застосунку і не повинен знати про HTTP. Репозиторій — це шар зберігання: він не знає і не повинен знати, хто його викликає — контролер, сервіс чи інопланетянин із тестів.

Щоб закріпити це «на рівні очей», зручно тримати в голові просту таблицю. Вона не про «ідеальну архітектуру», а про здоровий глузд у нашому курсі.

Питання Controller (api) Service (domain) Repository (domain + infrastructure)
Хто приймає HTTP-запит? Так Ні Ні
Хто вирішує, який HTTP-статус повернути? Так Ні Ні
Хто описує операції застосунку («створити задачу»)? Ні Так Ні
Хто генерує id і виставляє значення за замовчуванням? Ні (в ідеалі) Так Ні
Хто зберігає і читає дані? Ні Ні Так
Хто має «знати», що дані в пам’яті, а не в БД? Ні Ні Так (реалізація)

І ось тут з’являється важлива «стрілочна» схема. Її корисно пам’ятати, навіть якщо ви не любите схеми:

flowchart LR
    C["TaskController api.controller"] --> S["TaskService domain.service"]
    S --> R["TaskRepository domain.repository"]
    R --> IM["InMemoryTaskRepository infrastructure.repository.inmemory"]

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

3. Інтерфейс сервісу як договір

Інтерфейс сервісу — це договір між web-шаром і прикладним шаром. Важливо вловити тонкість: це не «список кінцевих точок», а «список прикладних операцій». Ми не називаємо методи getTasksEndpoint (це звучить як пародія на архітектуру), ми називаємо їх так, як думає домен: getAll, getById, create. Так, вони схожі на HTTP-операції, але сенс у тому, що сервіс не знає, як ми до нього дісталися — через HTTP, тест чи прямий виклик.

Мінімальний інтерфейс, який нам уже потрібен у проєкті на цьому етапі курсу, може виглядати так:

package com.example.tasktracker.domain.service;

import java.util.List;
import com.example.tasktracker.domain.model.Task;

/**
 * Контракт прикладних операцій над задачами.
 * Тут немає HTTP-деталей: лише те, що вміє застосунок.
 */
public interface TaskService {
    // Отримати всі задачі (як доменні об'єкти), без знання про те, хто їх запросив.
    List<Task> getAll();

    // Отримати задачу за id: HTTP і статуси тут не живуть, це вирішує контролер.
    Task getById(String taskId);

    // Створити задачу за правилами застосунку (наприклад, із генерацією id та значеннями за замовчуванням).
    Task create(Task task);
}

Зверніть увагу на два моменти. По-перше, сервіс працює з внутрішньою моделлю (Task). На цьому етапі це нормально: фокус дня — архітектура шарів. По-друге, інтерфейс не повертає ResponseEntity, не приймає @PathVariable і не знає нічого про @RequestBody. Сервісу байдуже, прийшов taskId із path-параметра чи ви написали його на папірці та передали в метод — сервіс бачить просто рядок.

Типове питання: «Навіщо interface, якщо можна одразу клас TaskService?». Для навчального проєкту є дві практичні відповіді. Перша — інтерфейс змушує вас думати: «Що саме ми обіцяємо», а не «Як зараз зручно написати». Друга — інтерфейс змушує контролер залежати від договору, а не від конкретного класу, і це різко знижує спокусу «помацати внутрішності» реалізації.

Якщо ви ловите себе на тому, що в інтерфейсі з’являються методи на кшталт getTasks(HttpServletRequest request), зупиніться, вдихніть, пригадайте схему зі стрілками і скажіть собі: «Я випадково посадив HTTP усередину домену». Нічого страшного, буває. Головне — повернути межу на місце, поки проєкт не виріс.

4. Інтерфейс репозиторію та зберігання

Репозиторій у нашому проєкті — це межа зберігання даних. Так, у нас поки немає бази даних, але це не скасовує самої ідеї межі. Репозиторій робить дві речі: зберігає і читає. Він не створює задачу за правилами домену, не вирішує, чи можна змінювати архівну задачу, і не ухвалює рішень про статуси відповіді. Він просто виконує операції зберігання, які просить сервіс.

Мінімальний інтерфейс репозиторію, який нам зараз потрібен, виглядає так:

package com.example.tasktracker.domain.repository;

import java.util.List;
import com.example.tasktracker.domain.model.Task;

/**
 * Контракт шару зберігання: лише операції читання й запису.
 * Бізнес-правила тут не живуть.
 */
public interface TaskRepository {
    // Повернути всі збережені задачі.
    List<Task> findAll();

    // Знайти задачу за id (може повернути null — залежить від домовленостей проєкту).
    Task findById(String taskId);

    // Зберегти задачу і повернути збережений об'єкт (наприклад, уже із заповненими полями).
    Task save(Task task);
}

Слово find тут спеціально натякає на те, що репозиторій не зобов’язаний «гарантувати наявність». Він може повернути null, може повернути Optional (ми зараз не будемо ускладнювати), може кинути виняток — це питання домовленостей. Важливо інше: репозиторій не повинен перетворюватися на «міні-сервіс» із бізнес-логікою. Якщо ви бачите, що репозиторій почав перевіряти «не можна зберігати, якщо статус ARCHIVED», то це вже не репозиторій, а сервіс у плащі репозиторію.

Ще один важливий момент: інтерфейс репозиторію зручно зберігати в domain, а конкретну реалізацію — в infrastructure. Тоді доменна частина проєкту не знає, що всередині там LinkedHashMap, ArrayList або, колись, база даних. Доменна частина знає лише: «є контракт, який уміє зберігати та знаходити».

І так, ви зараз можете подумати: «Але ж Spring Data потім сам зробить репозиторій». Так, але в цьому курсі ми свідомо без JPA. І саме тому ручний репозиторій — корисне тренування: ви починаєте поважати межу зберігання ще до того, як фреймворк дасть вам готову магію.

5. Конструкторна інʼєкція залежностей

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

Почнемо із сервісу-реалізації. Ми позначаємо її @Service, щоб Spring створив bean, і приймаємо TaskRepository у конструкторі. Зверніть увагу: залежність — це інтерфейс, а не конкретний InMemory... клас.

package com.example.tasktracker.domain.service;

import org.springframework.stereotype.Service;
import com.example.tasktracker.domain.repository.TaskRepository;

@Service
public class DefaultTaskService implements TaskService {
    // Залежність від контракту (інтерфейсу), а не від конкретного класу зберігання.
    private final TaskRepository taskRepository;

    // Конструкторна інʼєкція: залежності видно одразу, і їх неможливо "забути".
    public DefaultTaskService(TaskRepository taskRepository) {
        this.taskRepository = taskRepository;
    }
}

Це вже корисно навіть без методів: ми зробили зв’язок між шарами чесним. А тепер додамо один метод, щоб було видно, як сервіс делегує зберігання репозиторію.

import java.util.List;
import com.example.tasktracker.domain.model.Task;

@Override
public List<Task> getAll() {
    // Сервіс не зберігає колекцію сам: він запитує дані в репозиторію.
    return taskRepository.findAll();
}

Бачите сенс? Сервіс не зберігає колекцію, він просто просить репозиторій віддати дані. Це важливо: інакше сервіс починає бути «і сервісом, і репозиторієм», а це погана угода.

Тепер контролер. Тут важливо не сплутати дві різні речі: контролери поки ще оперують доменними об'єктами напряму, тому що нам потрібно зафіксувати межу controller -> service -> repository. Але це не робить внутрішню модель готовим публічним контрактом API. З цим акцентом подивімося на залежність контролера: він залежить від TaskService, а не від DefaultTaskService.

package com.example.tasktracker.api.controller;

import org.springframework.web.bind.annotation.RestController;
import com.example.tasktracker.domain.service.TaskService;

@RestController
public class TaskController {
    // Контролер залежить від договору (інтерфейсу), а не від конкретної реалізації.
    private final TaskService taskService;

    // Конструкторна інʼєкція робить залежність явною.
    public TaskController(TaskService taskService) {
        this.taskService = taskService;
    }
}

З погляду новачка тут є магія: «А звідки візьметься конкретна реалізація?». Відповідь проста: Spring знайде bean DefaultTaskService, тому що він позначений @Service і реалізує TaskService. Якщо реалізацій буде дві, Spring почне сваритися і попросить вас обрати, наприклад, через @Primary або @Qualifier. Ми цю тему сьогодні не розвиваємо — нам достатньо розуміти базовий принцип.

І ще одна маленька, але практична деталь: поля ми робимо final. Це не «стиль заради стилю». Це спосіб сказати компілятору і собі: «Без цієї залежності об’єкт не існує». Приблизно як чайник без води: формально чайник є, але чаю ви не отримаєте.

Наскрізний сценарій запиту

Коли код розпиляний на шари, корисно хоча б один раз «прокрутити плівку» в голові та побачити весь шлях. Це особливо допомагає, коли ви потім будете налагоджувати баги: ви перестаєте хаотично бігати по класу TaskController і починаєте мислити ланцюжком викликів. У нашому випадку ланцюжок простий: контролер отримує taskId, передає його сервісу, сервіс запитує репозиторій, репозиторій віддає модель.

Приклад методу для перегляду деталей у контролері — максимально коротко, без обговорення помилок:

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import com.example.tasktracker.domain.model.Task;

@GetMapping("/{taskId}")
public Task getTask(@PathVariable String taskId) {
    // @PathVariable — це турбота контролера: він "дістає" id з URL і передає далі як звичайний рядок.
    return taskService.getById(taskId);
}

А ось відповідний метод сервісу:

import com.example.tasktracker.domain.model.Task;

@Override
public Task getById(String taskId) {
    // Сервіс делегує читання в шар зберігання; HTTP-помилки тут не вирішуються.
    return taskRepository.findById(taskId);
}

І якщо зобразити це як мінідіаграму sequence, вийде дуже спокійна картина:

sequenceDiagram
    participant Client as "HTTP-клієнт"
    participant C as "TaskController"
    participant S as "DefaultTaskService"
    participant R as "TaskRepository"

    Client->>C: "GET /api/v1/tasks/{taskId}"
    C->>S: "getById(taskId)"
    S->>R: "findById(taskId)"
    R-->>S: "Task (або null)"
    S-->>C: "Task (або null)"
    C-->>Client: "JSON-відповідь"

Чому важливо так думати? Тому що це допомагає тримати межі відповідальності. Якщо ви бачите, що контролер раптом почав робити taskRepository.findById(...), діаграма ламається. Якщо сервіс почав приймати HttpServletRequest, діаграма ламається. А коли діаграма ламається — у вас не «особливий випадок», у вас архітектурний дрейф, який потім буде дуже дорого виправляти.

6. Межа сервісу: що не тягнути всередину

Сервіс — це прикладний шар. Він має бути максимально незалежним від web-рівня. Це не догма, це практичність: чим менше сервіс знає про HTTP, тим простіше його читати, повторно використовувати й розширювати. У межах цього курсу це ще й методична дисципліна: ми вчимося тримати web-шар тонким, щоб потім спокійно нарощувати DTO, validation, error handling та інші важливі частини.

Найчастіший «запах» поганої межі — коли сервіс починає повертати ResponseEntity або починає знати про статуси. Наприклад, так робити не варто — це анти-приклад:

import org.springframework.http.ResponseEntity;

public ResponseEntity<Task> getById(String taskId) {
    Task task = taskRepository.findById(taskId);
    return ResponseEntity.ok(task);
}

Чому це погано? Тому що сервіс раптом почав думати про HTTP. Він тепер зобов’язаний знати, який статус «правильний», які заголовки ставити, що робити за відсутності задачі. Це рішення web-шару. Навіть якщо сьогодні ви завжди повертаєте 200 OK, завтра захочете чеснішої поведінки, і тоді сервіс почне розростатися логікою, яку ви будете змушені тестувати як web-шар. А це вже змішування рівнів.

Інший неприємний варіант — тягнути в сервіс параметри «як прийшли», а не «що означають». Наприклад, якщо ви передаєте в сервіс @RequestParam чи @PathVariable анотаціями, це буквально означає, що web-шар протік усередину домену. Анотації мають жити в контролері, тому що вони описують контракт HTTP, а не контракт застосунку.

Хороший варіант — сервіс приймає звичайні Java-значення і повертає звичайні Java-об’єкти. Контролер відповідає за «переклад» між HTTP і Java-викликом. У підсумку сервіс залишається «чистим»: його можна викликати не лише з контролера, а й із тесту або з іншого сервісу, не притягуючи туди Spring MVC.

Є й тонший момент: де робити «невеликі доменні рішення», наприклад генерацію id під час створення задачі. Контролеру це робити не варто. Контролер — web-вхід, він не має права «вигадувати» сутність. Сервіс — це якраз місце, де створюється нова задача «за правилами застосунку».

Наприклад, так може виглядати створення задачі — спрощено й без обговорення контрактів тіла запиту:

import java.util.UUID;
import com.example.tasktracker.domain.model.Task;

@Override
public Task create(Task task) {
    // Генеруємо id на боці сервісу: контролер не має вигадувати сутності.
    String id = UUID.randomUUID().toString();

    // Створюємо об'єкт, який реально піде на збереження (наприклад, ігноруючи вхідний id, якщо він був).
    Task toSave = new Task(id, task.getTitle());

    // Збереження — відповідальність репозиторію.
    return taskRepository.save(toSave);
}

Тут важливо не те, що ми використовуємо UUID — це деталь. Важливо, що контролер не генерує ідентифікатор і не знає, як задача «правильно створюється». Він просто приймає вхідні дані й делегує їх сервісу. Це дає вам стійку точку, куди потім природно додадуться правила домену, наприклад «порожній title не можна» або «архівну задачу не можна створити». Але сьогодні ми туди не ліземо.

Нарешті, окрема пастка — «сервіс як дзеркало контролера». Коли ви просто копіюєте сигнатури кінцевих точок у сервіс, включно зі словами getTasks, postTasks, tasksControllerStyleNaming, ви підсвідомо прив’язуєте сервіс до HTTP-форми. Хороший сервіс частіше звучить як «операції предметної області», а не як «методи HTTP».

7. Типові помилки під час роботи із сервісним шаром

Помилка №1: впроваджувати репозиторій прямо в контролер, бо «так швидше».
Це працює рівно доти, доки у вас один успішний сценарій і один ресурс. Щойно з’являється логіка створення, перевірок і кілька сценаріїв, контролер роздувається: у ньому з’являються умови, генерація id, робота з колекціями і все те, що робить код важким для читання. Репозиторій — це залежність сервісу, а не контролера.

Помилка №2: протягувати HTTP-деталі в сервісний шар.
Коли сервіс починає повертати ResponseEntity, приймати HttpServletRequest, думати про статуси та заголовки, він перестає бути прикладним шаром і перетворюється на «контролер без анотацій». У результаті ви втрачаєте головний сенс шарів: сервіс уже не можна читати як «операції застосунку», він починає жити правилами транспорту.

Помилка №3: робити сервіс «універсальним божественним об’єктом», який і зберігає дані, і вирішує бізнес, і форматує відповіді.
Іноді новачок створює TaskService, складає туди Map із задачами і думає: «Репозиторій не потрібен». Це знову змішування відповідальності. Сервіс має використовувати репозиторій, а не замінювати його. Зберігання — окрема зона, навіть якщо це зберігання в пам’яті.

Помилка №4: залежати від конкретної реалізації замість інтерфейсу.
Якщо контролер приймає в конструкторі InMemoryTaskRepository, ви практично приклеїли web-шар до інфраструктури. Так, у навчальному проєкті це може «працювати», але ви втрачаєте гнучкість і ясність. Правильна залежність: TaskControllerTaskService (інтерфейс), DefaultTaskServiceTaskRepository (інтерфейс). Конкретні класи залишаються деталями збирання Spring.

Помилка №5: називати методи сервісу у стилі HTTP або у стилі «як у контролері», а не у стилі операцій застосунку.
Методи на кшталт postTask або tasksGetAll виглядають смішно, але це реальна помилка мислення: ви переносите транспортну форму всередину домену. Сервіс має звучати як «створити задачу», «отримати задачу», «отримати список задач», тому що це і є сенс прикладного шару. Якщо сервіс називається так само, як URL, ви зробили домен заручником маршрутизації.

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