1. Роль сервисного слоя
Когда проект маленький, очень хочется сделать так: контроллер получил запрос, взял Map, достал задачу, вернул. И кажется, что сервис — это лишняя прокладка. Но как только вы добавляете создание задач, обновления, ограничения домена (например, «архивную задачу нельзя менять») и несколько ресурсов (комментарии, вложения), контроллер превращается в “кухню” со сковородками, кастрюлями и проводами — и вы внезапно не понимаете, где у вас бизнес-решения, а где транспортные детали.
Сервисный слой в учебном проекте нужен не потому, что «так принято у взрослых». Он нужен как центральное место прикладных операций. Контроллер отвечает за HTTP-часть: как считать вход, как сформировать выход. Репозиторий отвечает за хранение. А сервис отвечает за смысл: что такое «создать задачу», «получить задачу», «получить список задач». Именно сервис — то место, где удобно держать решения вроде генерации идентификатора, заполнения полей по умолчанию и проверок предметных ограничений (не валидации входа аннотациями — это будет позже, сегодня не туда).
Есть ещё один очень прагматичный аргумент: сервис — это то, что можно читать как документацию проекта. Если у вас есть интерфейс TaskService, по нему понятно, что вообще умеет приложение. Если же вся логика размазана по контроллерам, то “документацией” становится коллекция аннотаций. А аннотации — отличная штука, но “описание возможностей приложения” из них получается как инструкция к микроволновке, написанная в виде исходников прошивки.
2. Роли Controller, Service, Repository
Если вы только начинаете, то самая частая путаница выглядит так: «Контроллер — это и есть backend». В реальности контроллер — это всего лишь 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"]
Смысл схемы не в том, что «всегда так». Смысл в том, что направление зависимостей делает проект читаемым. Контроллер зависит от сервиса (по интерфейсу), сервис зависит от репозитория (по интерфейсу), а инфраструктура подставляет конкретную реализацию хранения. Если вы держите эти стрелки в голове, вы почти автоматически перестаёте писать “fat controller”.
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, передаёт сервису, сервис запрашивает репозиторий, репозиторий отдаёт модель.
Пример метода detail endpoint в контроллере (максимально коротко, без обсуждения ошибок):
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 Client"
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 (or null)"
S-->>C: "Task (or null)"
C-->>Client: "JSON response"
Почему важно так думать? Потому что это помогает держать границы ответственности. Если вы видите, что контроллер внезапно начал делать 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: инжектить репозиторий прямо в контроллер, потому что “так быстрее”.
Это работает ровно до тех пор, пока у вас один happy-path и один ресурс. Как только появляется логика создания, проверок и несколько сценариев, контроллер раздувается: в нём появляются условия, генерация id, работа с коллекциями и всё то, что делает код трудночитаемым. Репозиторий — это зависимость сервиса, а не контроллера.
Ошибка №2: протаскивать HTTP-детали в сервисный слой.
Когда сервис начинает возвращать ResponseEntity, принимать HttpServletRequest, думать о статусах и заголовках, он перестаёт быть прикладным слоем и превращается в “контроллер без аннотаций”. В результате вы теряете главный смысл слоёв: сервис уже нельзя читать как «операции приложения», он начинает жить правилами транспорта.
Ошибка №3: делать сервис “универсальным божественным объектом”, который и хранит данные, и решает бизнес, и форматирует ответы.
Иногда новичок создаёт TaskService, складывает туда Map с задачами и думает: «репозиторий не нужен». Это снова смешение ответственности. Сервис должен использовать репозиторий, а не заменять его. Хранение — отдельная зона, даже если это хранение в памяти.
Ошибка №4: зависеть от конкретной реализации вместо интерфейса.
Если контроллер принимает в конструкторе InMemoryTaskRepository, вы практически приклеили web-слой к инфраструктуре. Да, в учебном проекте это может “работать”, но вы теряете гибкость и ясность. Правильная зависимость: TaskController → TaskService (интерфейс), DefaultTaskService → TaskRepository (интерфейс). Конкретные классы остаются деталями сборки Spring.
Ошибка №5: называть методы сервиса в стиле HTTP или в стиле “как в контроллере”, а не в стиле операций приложения.
Методы вида postTask или tasksGetAll выглядят смешно, но это реальная ошибка мышления: вы переносите транспортную форму внутрь домена. Сервис должен звучать как “создать задачу”, “получить задачу”, “получить список задач”, потому что это и есть смысл прикладного слоя. Если сервис называется так же, как URL, вы сделали домен заложником маршрутизации.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ