1. Роли controller, service, repository
Если вы когда-либо писали небольшой проект “на коленке”, есть шанс, что у вас был один класс, который делал примерно всё: хранил список, фильтровал его, печатал результат, а иногда ещё и рисовал интерфейс (в консоль, но всё равно интерфейс). Это работает ровно до тех пор, пока проект не переживает второй вечер разработки. На третий вечер он начинает мстить.
Проблема в том, что “один класс на всё” очень быстро превращается в класс без роли. Вы открываете файл и не понимаете: он про бизнес-правила? Про хранение данных? Про формат вывода? Про старт приложения? В Boot-проекте мы специально боремся с такой кашей, потому что Spring даёт нам шикарную возможность: собрать приложение из маленьких компонентов, но только если мы сами договоримся о границах.
Чтобы границы не были абстрактной философией, давайте зафиксируем простую рабочую модель. Она не “единственно правильная”, но для Junior — золотая середина.
| Роль | Главный вопрос | Что НЕ должен делать |
|---|---|---|
| repository | “Откуда приходят данные?” | Не должен решать бизнес-правила и “как правильно” фильтровать |
| service | “Что мы хотим сделать с данными?” | Не должен знать детали хранения и не должен быть HTTP-слоем |
| controller | “Как нас вызывают снаружи?” | Не должен хранить данные и не должен содержать бизнес-логику |
И ещё один важный нюанс: наш проект read-only. Значит, мы сознательно не строим “CRUD-мир” и не изображаем базу данных там, где её нет. У нас нет операций записи, нет транзакций, нет JPA-репозиториев — и это не “упрощение ради упрощения”, а защита фокуса курса.
2. repository: источник данных и контракт
Слово “репозиторий” у многих новичков автоматически вызывает в голове картинку “таблица в базе данных”. Но репозиторий — это шире: это абстракция доступа к данным. Сегодня данные живут в памяти, завтра — в файле, послезавтра — в конфигурации или вообще за HTTP. Репозиторий нужен, чтобы остальной код не переживал из-за таких перемен.
Поскольку проект read-only, репозиторий у нас будет максимально честным: он просто отдаёт курсы. Не сохраняет, не обновляет, не делает вид, что он ORM. Сначала мы определяем контракт (интерфейс), и только потом — реализацию. Это важная привычка: сначала вы формулируете “что нужно приложению”, а не “как я это сейчас быстренько сделаю”.
Начнём с доменной модели. Для учебного проекта нам достаточно очень маленькой карточки курса: slug, title, флаги featured и published. Чтобы код был коротким и неизменяемым “по умолчанию”, удобно использовать record.
package com.example.catalogservice.catalog.domain;
// Доменная модель: простая карточка курса без Spring-аннотаций и логики хранения.
public record CourseCard(String slug, String title, boolean featured, boolean published) {
// Явные "читабельные" методы — чтобы в stream'ах было проще читать фильтры.
public boolean isFeatured() { return featured; }
// Отдельно подчёркиваем смысл "опубликованности" (а не просто доступ к полю).
public boolean isPublished() { return published; }
}
Теперь контракт репозитория. Минимальный и понятный: “дай все курсы”.
package com.example.catalogservice.catalog.repository;
import java.util.List;
import com.example.catalogservice.catalog.domain.CourseCard;
// Контракт доступа к данным каталога (read-only).
public interface CourseCatalogRepository {
// Возвращает все курсы, которые доступны репозиторию (без бизнес-фильтров).
List<CourseCard> findAll();
}
Обратите внимание на стиль: метод называется findAll, а не getAllData и не doCatalog. Это маленькая вещь, но именно из таких мелочей складывается читаемость.
Теперь простейшая in-memory реализация. Да, пока она возвращает пустой список — это нормально: мы строим каркас и роли. Данные добавим чуть позже.
package com.example.catalogservice.catalog.repository;
import java.util.List;
import org.springframework.stereotype.Repository;
import com.example.catalogservice.catalog.domain.CourseCard;
@Repository
public class InMemoryCourseCatalogRepository implements CourseCatalogRepository {
@Override
public List<CourseCard> findAll() {
// На этом этапе репозиторий честно отвечает: "данных пока нет".
// Позже заменим на тестовый in-memory набор.
return List.of();
}
}
И вот здесь полезно проговорить границу ответственности. Репозиторий отвечает на вопрос “откуда данные”, но не обязан “умничать”. Если вы начнёте засовывать в репозиторий правила вида “featured — это только опубликованные и только первые 3”, вы постепенно превратите его в смесь “хранилище + бизнес-логика”, и у вас пропадёт место, где должны жить решения приложения.
Чтобы примеры дальше были не в вакууме, добавим тестовые данные. Это пока учебный in-memory набор, без претензий на взрослую архитектуру.
import java.util.List;
import com.example.catalogservice.catalog.domain.CourseCard;
// Учебный набор данных (обычно это поле внутри InMemory-репозитория).
// List.of(...) даёт неизменяемый список: случайно не "подпортим" данные по дороге.
private final List<CourseCard> courses = List.of(
new CourseCard("spring-boot", "Spring Boot", true, true),
new CourseCard("spring-core", "Spring Core", false, true),
new CourseCard("docker", "Docker for Spring", true, false)
);
А теперь репозиторий может отдавать courses вместо пустого списка. Важно: List.of(...) возвращает неизменяемый список, то есть мы случайно не “подпортим” данные где-то по дороге.
3. service: решения приложения
Очень популярная мысль у новичка: “Зачем сервис, если он просто вызывает репозиторий?” И мысль честная: в маленьком проекте сервис действительно сначала выглядит как прокладка. Но сервис — это не “прокладка ради прокладки”, а место, где живут решения приложения. Даже если сейчас решений мало, у вас должно быть место, куда эти решения естественно положить.
Представьте, что завтра вам понадобилось “отдавать только опубликованные курсы”, “показывать featured-курсы в ограниченном количестве”, “уметь скрывать неопубликованные в одном режиме и показывать в другом”. Если у вас нет service-слоя, все такие правила начинают расползаться либо в контроллер, либо в репозиторий. А это как раз то, от чего мы уходим.
Создадим CourseCatalogService. Он зависит от CourseCatalogRepository и получает его через конструктор.
package com.example.catalogservice.catalog.service;
import java.util.List;
import org.springframework.stereotype.Service;
import com.example.catalogservice.catalog.domain.CourseCard;
import com.example.catalogservice.catalog.repository.CourseCatalogRepository;
@Service
public class CourseCatalogService {
// Сервис знает только про контракт репозитория, а не про его реализацию.
private final CourseCatalogRepository repository;
public CourseCatalogService(CourseCatalogRepository repository) {
// В реальном проекте сюда можно добавить проверку на null,
// но в Spring обычно контракт "репозиторий всегда будет".
this.repository = repository;
}
public List<CourseCard> findAllCourses() {
// Пока это просто делегирование — это нормально на этапе каркаса.
return repository.findAll();
}
}
Пока сервис действительно “делегирует”. И это нормально. Теперь добавим маленькое прикладное правило: featured-курсы — это те, у которых featured = true, и мы хотим видеть только опубликованные. Это уже похоже на “решение приложения”, а не на “техническую деталь хранения”.
import java.util.List;
import com.example.catalogservice.catalog.domain.CourseCard;
public List<CourseCard> findFeaturedCourses() {
return repository.findAll().stream()
// Сначала отбрасываем неопубликованные (решение каталога, а не способ хранения).
.filter(CourseCard::isPublished)
// Затем оставляем только featured.
.filter(CourseCard::isFeatured)
// Собираем результат обратно в список.
.toList();
}
Здесь важно не только то, что мы написали фильтрацию, а где мы её написали. Фильтрация — это логика поведения каталога, значит это service. Репозиторий по-прежнему отвечает за “дать данные”, а сервис — за “как мы эти данные интерпретируем”.
Есть ещё одна причина любить service-слой: тестируемость. Даже без специальных Spring-тестов вы сможете создать сервис вручную, передав фейковый репозиторий. Когда логика живёт в контроллере, тестировать её становится тяжелее, потому что контроллер — это уже “граница” (в будущем HTTP), а границы тестировать дороже и сложнее.
4. controller: вход в приложение
Слово controller в Spring обычно ассоциируется с HTTP и аннотациями вроде @GetMapping. Но здесь нам важнее другое: контроллер — это тонкий входной адаптер. Его задача — принять внешний вызов и передать управление в service-слой, а не решать, откуда берутся данные и как работает каталог.
Если перевести на бытовую аналогию, контроллер — это “администратор на входе”. Он не готовит еду (это service), не хранит продукты на складе (это repository), а просто принимает заказ и передаёт его на кухню. Плохой администратор — тот, кто начинает сам резать салат прямо на стойке, потому что “так быстрее”.
Создадим скелет контроллера. Он зависит только от сервиса. Это принципиально: контроллер не должен знать, откуда берутся данные.
package com.example.catalogservice.catalog.web;
import org.springframework.stereotype.Controller;
import com.example.catalogservice.catalog.service.CourseCatalogService;
@Controller
public class CourseCatalogController {
// Контроллер зависит только от service-слоя (не от repository).
private final CourseCatalogService service;
public CourseCatalogController(CourseCatalogService service) {
// Контроллер — тонкий слой: получил зависимость и делегирует дальше.
this.service = service;
}
}
Почему здесь @Controller, а не @RestController? Потому что нам сейчас важна роль компонента, а не HTTP-механика. Достаточно видеть его как тонкий входной адаптер: класс находится на верхней границе прикладного слоя и делегирует работу сервису.
5. Цепочка controller → service → repository
Когда вы только начинаете, DI и бины могут восприниматься как “магия аннотаций”. Чтобы магия исчезла, полезно мысленно рисовать проводку: кто от кого зависит и куда течёт вызов. И вот тут слои controller/service/repository дают очень понятную картину, как у электрической схемы: есть вход, есть логика, есть источник данных.
Схема для нашего catalog-service должна быть простой, почти детской. И это комплимент.
flowchart LR
C[CourseCatalogController] --> S[CourseCatalogService]
S --> R[CourseCatalogRepository]
R --> D[(In-memory courses)]
Здесь важна направленность зависимостей: контроллер знает сервис, сервис знает репозиторий, репозиторий знает доменные классы (CourseCard) и своё внутреннее хранение. В обратную сторону зависимости не идут.
Чтобы почувствовать это на уровне кода, добавим в контроллер метод, который просто делегирует в сервис. Он пока не “HTTP-метод”, просто обычный Java-метод. Нам важно увидеть направление вызова и то, что контроллер не лезет в репозиторий напрямую.
import java.util.List;
import com.example.catalogservice.catalog.domain.CourseCard;
public List<CourseCard> featuredCourses() {
// Контроллер не фильтрует сам — он делегирует use case в сервис.
return service.findFeaturedCourses();
}
Теперь сравните это с “плохим” вариантом, который очень соблазнительно написать, когда хочется “просто сделать быстрее”: контроллер получает репозиторий и фильтрует прямо в себе. Он вроде бы работает… но роль контроллера размывается.
import java.util.List;
public List<CourseCard> featuredCourses() {
// Плохой признак: контроллер начинает знать про данные и правила фильтрации.
return repository.findAll().stream()
.filter(CourseCard::isFeatured)
.toList();
}
Разница между двумя версиями не в количестве строчек, а в том, где будет жить логика, когда её станет больше. В хорошем варианте логика естественно растёт в сервисе. В плохом — контроллер превращается в “всё-в-одном”.
6. Границы repository и service
В реальных проектах граница между repository и service иногда бывает тонкой, и это нормально. Но у Junior часто проблема другая: границы нет вообще, потому что “и так работает”. Поэтому здесь полезно держать в голове простое правило: репозиторий — это доступ к данным, сервис — это смысл.
Если вы пишете код и ловите себя на мысли “я тут решаю, какие курсы правильные/неправильные, какие показывать, какие скрывать, как ограничить список” — это почти наверняка service-слой. Если вы ловите себя на мысли “мне нужно достать данные” — это repository. Даже в in-memory варианте эта дисциплина важна: вы тренируете архитектурное мышление на безопасном учебном домене.
При этом репозиторий не обязан быть “тупой трубой”. Он может содержать технически оправданные методы поиска, но только если они остаются в логике доступа к данным. Например, “найти курс по slug” — это не бизнес-правило, это способ доступа к элементу данных.
import java.util.Optional;
public Optional<CourseCard> findBySlug(String slug) {
// Это технический метод доступа к данным: "найти элемент по ключу".
// Тут нет "решения каталога", только поиск по коллекции.
return findAll().stream()
.filter(c -> c.slug().equals(slug))
.findFirst();
}
А вот “показать featured, но не больше 3, и только опубликованные” — это уже service, потому что это решение поведения каталога, а не способ доступа.
public List<CourseCard> findFeaturedCourses(int limit) {
return repository.findAll().stream()
// Бизнес-решение каталога: показываем только опубликованные.
.filter(CourseCard::isPublished)
// Бизнес-решение каталога: показываем только featured.
.filter(CourseCard::isFeatured)
// Ещё одно решение: ограничиваем количество.
.limit(limit)
.toList();
}
Ещё одна типичная ловушка — делать сервис “пассивной прокладкой” навсегда. В учебном проекте это может случиться, если вы боитесь добавить туда хоть одно правило. Но service как раз и нужен, чтобы правила жили в одном месте. Пусть это будет даже маленькое правило — фильтр, сортировка, ограничение. Главное — чтобы была ясная “точка смысла”.
7. Именование методов
Когда архитектура только появляется, кажется, что имена методов — это косметика. Но в Spring-проекте, где половина понимания идёт через чтение кода и wiring, имена — это навигационные знаки. Хорошее имя отвечает на вопрос “что делает метод”, а не “как-то оно работает”.
Для repository хороши имена в стиле “поиск/получение данных”: findAll, findBySlug, existsBySlug. Они звучат скучно — и это отлично. Репозиторий не должен быть творческим. Чем скучнее его API, тем спокойнее ваш мозг.
Для service хороши имена, которые отражают use case: findFeaturedCourses, getPublishedCourses, buildCatalogSummary. Тут уже можно быть “человечнее”, потому что сервис — это язык приложения. Но даже здесь избегайте слов типа process, handle, doWork: они обычно означают “я не придумал нормальное имя”.
Для controller имена методов чаще всего будут отражать “внешний сценарий”: “получить список”, “получить карточку”. Но пока мы не пишем web API, лучше не разгоняться в сторону HTTP-терминов. Нам достаточно того, что контроллер вызывает сервис, а не репозиторий.
И важный приём для самопроверки: откройте класс и попробуйте понять его роль, не читая тела методов. Если по именам методов и по зависимостям в конструкторе вы примерно понимаете, что это за класс — вы на правильном пути.
8. Типичные ошибки при разделении слоёв
В этой теме ошибки обычно не “красные” (приложение же всё равно стартует), а “архитектурные”: они проявляются позже, когда код начинает расти. Поэтому полезно ловить их в самом начале, пока проект маленький и послушный.
Ошибка №1: контроллер зависит от репозитория напрямую.
Сначала кажется, что это экономит один класс: “зачем мне сервис, если я и так могу достать данные?”. Но потом контроллер начинает обрастать фильтрами, ограничениями, правилами, и внезапно он уже не контроллер, а комбайн. Привычка держать зависимость controller → service защищает проект от этого расползания.
Ошибка №2: бизнес-правила прячутся в репозитории “потому что там удобнее фильтровать”.
В in-memory реализации действительно удобно сделать findFeatured() прямо в репозитории, потому что “ну это же тоже поиск”. Но как только правило становится не просто “поиск”, а “решение”, репозиторий перестаёт быть слоем данных. Граница проста: репозиторий отвечает на вопрос “как достать”, сервис — “что считать правильным результатом”.
Ошибка №3: сервис остаётся пустой прокладкой и не имеет своей роли.
Иногда сервис создают “потому что так сказали”, но боятся положить туда хоть одно правило. В итоге логика всё равно утекает в контроллер или в репозиторий. Если сервис существует, дайте ему хотя бы минимальную ответственность: фильтрацию published/featured, ограничение списка, выборку “витрины”. Это укрепляет роль слоя и делает код читаемым.
Ошибка №4: интерфейс репозитория превращается в свалку методов.
Новичок может начать добавлять методы “на всякий случай”: getAll, getAllCourses, getCourses, loadCourses, readCourses. В результате интерфейс пухнет, но ясности не добавляет. Лучше держать контракт маленьким и расширять его только по реальному сценарию, а не по тревоге “вдруг пригодится”.
Ошибка №5: слои знают слишком много друг о друге.
Классический симптом — когда доменные классы начинают импортировать Spring-аннотации, а репозиторий внезапно “понимает”, как контроллер будет работать. В учебном проекте легко удержать чистоту: домен (CourseCard) — это просто данные, репозиторий — доступ к ним, сервис — логика, контроллер — вход. Чем меньше “перекрёстных знаний”, тем спокойнее развивается проект дальше.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ