JavaRush /Курсы /Spring Boot /Собираем граф зависимостей

Собираем граф зависимостей

Spring Boot
7 уровень , 4 лекция
Открыта

1. Wiring и граф зависимостей

Когда говорят «wiring в Spring», у новичка часто в голове появляется картинка: «ну это когда я поставил @Service, а оно само». Это нормально, мы все через это проходили. Но сегодня полезно чуть сдвинуть фокус: wiring — это не про аннотации как наклейки, а про граф зависимостей, то есть про то, кто от кого зависит, и насколько этот граф читаем без шаманских плясок.

Представьте, что вы собираете настольную лампу из деталей. Плафон, стойка, провод, выключатель, вилка. Можно, конечно, каждый раз паять провод заново прямо внутри плафона («так быстрее, я же знаю, как»). А можно собрать аккуратно: провод отдельно, выключатель отдельно, соединители на своих местах. В первом случае лампа вроде горит, но любой ремонт превращается в приключение уровня «снимем плафон — и случайно развалится полквартиры». Во втором случае лампа живёт долго и чинится без нервов.

В catalog-service wiring — это, по сути, ответ на три вопроса, которые мы хотим, чтобы читатель кода понимал за минуту: где начинается сборка приложения, какие у нас прикладные компоненты и как они связаны, где у нас инфраструктурные «помощники» и как они попадают в контейнер. Если wiring чистый, то конструкторами можно «прочитать» архитектуру так же легко, как карту метро: видно линии, пересадки и конечные станции.

Для наглядности можно держать в голове простую схему. В большинстве наших примеров стрелки будут смотреть сверху вниз:

flowchart TD
    Controller --> Service --> Repository
    Service --> Support

Это и есть базовая форма «чистого» wiring для небольшого сервиса: верхние слои зависят от нижних, но не наоборот. Как только стрелки начинают идти хаотично во все стороны, проект быстро превращается в «спагетти-контейнер», где любое изменение вызывает эффект домино.

2. Главный пакет и main-класс: точка сборки

Чтобы этот граф вообще собрался, нам нужен один базовый факт про структуру проекта: @SpringBootApplication лежит в верхнем пакете приложения, а всё, что должно попасть в component scan, живёт ниже него. Wiring должен быть не только логичным на бумаге, но и видимым для контейнера.

Вот поэтому main-класс остаётся в com.example.catalogservice:

package com.example.catalogservice;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication // Точка входа: задаёт границы component scan для всего приложения
public class CatalogServiceApplication {
    public static void main(String[] args) {
        // Запуск Spring-контейнера и автоконфигурации приложения
        SpringApplication.run(CatalogServiceApplication.class, args);
    }
}

Для текущего baseline этого достаточно: catalog, config, support и actuator попадают в одну зону сканирования, и граф зависимостей не рвётся из-за неудачно выбранного пакета.

3. Базовый граф зависимостей в catalog-service

Теперь соберём всё в одну картину. Нам не нужен сложный домен, не нужны “enterprise-слои ради слоёв”, нам нужен понятный учебный сервис. Поэтому граф зависимостей для текущего состояния проекта должен выглядеть максимально прямолинейно: контроллер зависит от сервиса, сервис зависит от репозитория, а репозиторий умеет отдавать данные из памяти. Дополнительно сервис может зависеть от маленького инфраструктурного помощника из support (например, нормализатора slug), чтобы показать, как подключать такие штуки без new.

Нарисуем это как граф. Он чуть подробнее, чем «три прямоугольника», но всё ещё легко читается:

flowchart TD
    C[CourseCatalogController] --> S[CourseCatalogService]
    S --> R[CourseCatalogRepository]
    R --> IM[InMemoryCourseCatalogRepository]
    S --> N[CatalogSlugNormalizer]

Именно такую собранную картину дальше и держим как нормальный baseline catalog-service.

Ключевой момент: доменные объекты (например, CourseCard) сюда не рисуем как бины. Это обычные Java-объекты данных. Они создаются репозиторием/сервисом как результат работы, но не живут в контейнере как инфраструктурные компоненты.

Начнём снизу. Репозиторий — это контракт. Даже если реализация одна, интерфейс задаёт понятную границу: «вот так мы читаем данные каталога».

package com.example.catalogservice.catalog.repository;

import com.example.catalogservice.catalog.domain.CourseCard;
import java.util.List;

// Контракт репозитория: описывает, ЧТО можно получить, но не КАК это устроено внутри
public interface CourseCatalogRepository {
    // Возвращаем все карточки курсов из каталога (источник данных не важен для вызывающего кода)
    List<CourseCard> findAll();
}

Реализация у нас in-memory, поэтому она максимально простая и честная: без имитации JPA, без «EntityManager в душе», просто класс, который умеет вернуть список.

import java.util.List;
import org.springframework.stereotype.Repository;

@Repository // Spring сам найдёт реализацию репозитория через component scan
public class InMemoryCourseCatalogRepository implements CourseCatalogRepository {

    @Override
    public List<CourseCard> findAll() {
        // Заглушка для учебного примера: позже здесь появятся реальные данные
        return List.of();
    }
}

Сервис получает репозиторий через конструктор. Здесь мы даже можем не показывать методы сервиса — с точки зрения wiring нас интересует главное: зависимость явна, поле final, сервис нельзя создать «пустым».

import org.springframework.stereotype.Service;

@Service // Прикладной сервис: точка, где живёт бизнес-логика (а не сборка зависимостей)
public class CourseCatalogService {

    private final CourseCatalogRepository repository; // Явная зависимость сервиса от контракта репозитория

    public CourseCatalogService(CourseCatalogRepository repository) {
        // Constructor injection: зависимость обязательна для корректной работы объекта
        this.repository = repository;
    }
}

Контроллер, в свою очередь, зависит от сервиса. Нам не нужны сейчас request mappings; здесь важна сама граница controller → service. Контроллер — это верхняя точка прикладной части, он не должен знать про детали хранения данных.

import org.springframework.stereotype.Controller;

@Controller // Заготовка входного web-адаптера: верхняя граница прикладного слоя
public class CourseCatalogController {

    private final CourseCatalogService service; // Контроллер зависит только от сервиса

    public CourseCatalogController(CourseCatalogService service) {
        // DI через конструктор делает зависимость видимой и обязательной
        this.service = service;
    }
}

Если остановиться здесь и посмотреть на четыре класса, уже видно главное: wiring читается по конструкторам. Мы не должны бегать по проекту и искать, где же создаётся репозиторий, кто его кладёт в сервис, и почему контроллер внезапно умеет читать данные сам. Стрелки однозначны.

И вот здесь появляется важная привычка, которую стоит развивать: в прикладных классах не должно быть “случайного new” для ключевых зависимостей. Когда вы пишете new внутри сервиса или контроллера, вы вытаскиваете часть wiring из контейнера и прячете её в коде, который сложно заменить, тестировать и поддерживать. new сам по себе не преступление (Java без new пока не завелась), но в прикладных слоях controller/service/repository он должен появляться очень осознанно и редко.

4. Регистрация бина: stereotype или @Bean

Когда проект маленький, кажется, что @Service и @Repository решают всё. И правда: component scan находит классы, Spring создаёт их, связывает зависимости — красота. Но как только у вас появляется объект, который не хочется делать «спринговым» (например, вы хотите оставить его чистым Java-классом без аннотаций), или объект вообще сторонний (вы его не контролируете), нужен второй инструмент: @Configuration + @Bean.

Здесь важно поймать правильное ощущение. Stereotype-аннотации — это «Spring, пожалуйста, найди этот класс и создай его как компонент». А @Bean — это «Spring, вот конкретный способ создать объект: вызови этот метод и запомни результат как бин». Оба подхода нормальные, и зрелость разработчика — не в том, чтобы выбрать один навсегда, а в том, чтобы выбирать по смыслу.

Удобно держать это сравнение в маленькой таблице:

Как регистрируем бин Когда удобно Что получаем в итоге
@Component / @Service / @Repository / @Controller Мы владеем кодом класса, и он действительно “компонент приложения” Spring сам найдёт класс через scan и создаст бин
@Configuration + @Bean Класс хочется оставить «чистым», или это сторонний объект, или нужен явный способ создания Явное правило создания бина, понятное из одного места

Покажем это на примере маленького «помощника» — нормализатора slug. Это класс из support: он не про домен, не про web, а про мелкую инфраструктурную логику. Его приятно держать без Spring-аннотаций, чтобы он оставался обычным Java-классом (и мог быть переиспользован хоть в другом проекте, хоть в тесте, хоть в консольной утилите).

package com.example.catalogservice.support;

public class CatalogSlugNormalizer {

    public String normalize(String slug) {
        // Мини-инфраструктурная логика: приводим slug к «каноническому» виду
        return slug.trim().toLowerCase();
    }
}

Теперь мы хотим, чтобы сервис мог получить его через DI. Для этого заведём конфигурационный класс:

package com.example.catalogservice.config;

import com.example.catalogservice.support.CatalogSlugNormalizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration // Конфигурация: место, где описываем правила сборки инфраструктурных объектов
public class SupportConfiguration {

    @Bean
    public CatalogSlugNormalizer catalogSlugNormalizer() {
        // Явно создаём объект и отдаём его Spring как бин (класс при этом остаётся «чистым»)
        return new CatalogSlugNormalizer();
    }
}

И подключим нормализатор в сервис. Обратите внимание: сервис не создаёт его сам, он просто честно говорит: «мне это нужно».

import org.springframework.stereotype.Service;

@Service
public class CourseCatalogService {

    private final CourseCatalogRepository repository;
    private final CatalogSlugNormalizer slugNormalizer; // Ещё одна зависимость: маленький инфраструктурный помощник

    public CourseCatalogService(CourseCatalogRepository repository,
                                CatalogSlugNormalizer slugNormalizer) {
        // Все зависимости объявлены явно — wiring читается «по конструктору»
        this.repository = repository;
        this.slugNormalizer = slugNormalizer;
    }
}

И здесь полезно не смешивать два уровня wiring. Одно дело — выбрать бин среди уже зарегистрированных кандидатов (@Primary, @Qualifier, Optional, List). Другое — решить, какие бины вообще должны попасть в контекст для конкретного окружения. В текущей базовой картине у нас одна активная реализация по умолчанию; если граф должен отличаться по profile/property, это уже вопрос регистрации, а не самой точки инъекции.

В итоге wiring остаётся чистым: зависимости перечислены в конструкторе, создание инфраструктурного объекта живёт в конфигурации, а сам CatalogSlugNormalizer не превращается в «магический» класс, который нельзя использовать без Spring. Это маленькая вещь, но она очень хорошо дисциплинирует проект: вы начинаете разделять «кто делает работу» и «кто отвечает за сборку деталей».

5. Маленькие конфиги вместо “god-config”

Как только вы полюбили @Bean, появляется соблазн: «а давайте сделаем один класс AppConfiguration и сложим туда всё». На первых порах это даже кажется удобным: открываешь один файл и видишь все бины. А потом наступает день, когда в файле 200 строк, половина бинов относится к web, половина — к каталогу, треть — к каким-то support-штукам, и вишенка на торте — комментарий “TODO: refactor later”. Это и есть тот самый “god-config”: всемогущая конфигурация, которая знает всё обо всём, и поэтому не читается никем.

Плохая новость: такой файл быстро становится «точкой боли». Хорошая новость: лечится он очень простым правилом. Один конфиг-класс — одна тема. Тема может быть “support”, “startup”, “catalog wiring”, но не “вся Вселенная и ещё два бина на всякий случай”.

Плохой стиль можно увидеть даже по форме кода. Обычно он выглядит так (и да, эти «и ещё 20 бинов» появляются удивительно быстро):

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration // Пример «как не надо»: один класс начинает знать обо всём проекте сразу
public class AppConfiguration {

    @Bean
    public CatalogSlugNormalizer slugNormalizer() {
        // Инфраструктура
        return new CatalogSlugNormalizer();
    }

    @Bean
    public CourseCatalogService courseCatalogService(...) {
        // Прикладной бин, который внезапно начинает собираться тут же
        return new CourseCatalogService(...);
    }

    // и ещё 20 бинов, потому что "куда-то же надо положить"
}

Хороший стиль — это несколько маленьких конфигураций, каждая в своём пакете config, каждая отвечает за свою зону. В нашем текущем состоянии проекта уже логично иметь хотя бы SupportConfiguration, а дальше (по мере появления новых инфраструктурных объектов) вы просто добавляете новые тематические конфиги, не раздувая один файл.

Чтобы было проще «увидеть» это как структуру, можно представить в виде дерева:

com.example.catalogservice
|-- CatalogServiceApplication
|-- config
|   |-- SupportConfiguration
|   `-- (другие тематические конфиги появятся по мере роста)
|-- catalog
|   |-- repository
|   |-- service
|   `-- web
`-- support
    `-- CatalogSlugNormalizer

Важно, что конфигурация — это тоже часть wiring, и её читаемость не менее важна, чем читаемость сервиса. Если вы научитесь держать конфиги маленькими, у вас будет очень простой бонус: когда что-то ломается на старте (а на старте ломается всё самое интересное), вы быстрее понимаете, «где у нас кусок сборки», и не бегаете по проекту как по лабиринту.

6. Чтение wiring: проверки и диагностика

Даже если вы сделали всё красиво, первый запуск после рефакторинга wiring иногда заканчивается тем, что приложение не стартует, а Spring печатает вам полотно текста. В этот момент очень хочется сказать: «Spring, хватит, я всё понял» — но он, как хороший преподаватель, продолжает объяснять, пока вы не начнёте читать сообщение. И это, кстати, полезная привычка: ошибки wiring в Spring обычно довольно разговорчивые, просто их нужно научиться «переводить с spring-ского на человеческий».

Самая частая ошибка — «бин не найден». В логе это выглядит примерно так: Spring говорит, что в конструкторе какого-то класса есть параметр типа X, а бина типа X в контексте нет. Причины обычно приземлённые: класс не помечен stereotype-аннотацией, конфигурация не подхватилась сканированием, или вы вынесли пакет за пределы main-пакета. Именно поэтому мы так педантично держим CatalogServiceApplication в верхнем пакете и следим за структурой проекта.

Вторая частая ошибка — «бинов несколько, а какой выбрать — непонятно». Это случается, когда у интерфейса две реализации, а вы пытаетесь внедрить «просто по типу». Тогда Spring честно признаётся: «я нашёл два кандидата, но не хочу выбирать монеткой». Здесь и вступают в игру @Primary и @Qualifier: они дают контейнеру явное правило выбора. Важно понять психологический момент: это не “Spring вредничает”, это “Spring защищает вас от случайного выбора”, который потом будет очень сложно отследить.

Третья категория проблем — не ошибка старта, а «медленный и странный рост конструктора». Вы добавили одну зависимость, потом ещё одну, потом “на секундочку” десятую — и внезапно класс стал похож на многорукого Шиву. Это не повод паниковать, но это хороший сигнал: класс тянет на себя слишком много ответственности. Clean wiring тут помогает хотя бы тем, что проблема видна сразу: длинный конструктор бросается в глаза без дебаггера и без запуска.

И наконец, маленькая, но очень практичная проверка, которая отлично дисциплинирует мышление. Если ваш класс использует constructor injection, вы в любой момент можете мысленно (или даже буквально) собрать его вручную как обычный Java-объект: создать репозиторий, создать нормализатор, передать их в конструктор сервиса. Не для того, чтобы так писать в проде, а чтобы проверить, что зависимости действительно «собираемы» и не требуют скрытой магии. Если класс невозможно создать без того, чтобы Spring “как-то сам догадался”, это часто признак того, что wiring расползается в неявность.

7. Типичные ошибки при wiring

На практике 80% проблем с wiring у начинающих повторяются так часто, что их можно смело коллекционировать как покемонов (только, пожалуйста, не надо). Хорошая новость: большинство из них лечатся одним и тем же лекарством — делать зависимости явными и держать структуру проекта читаемой. Ниже — самые частые «грабли» именно по теме clean wiring.

Ошибка №1: new внутри сервиса “потому что так быстрее”.
Это классика жанра: сервис создаёт репозиторий сам, репозиторий создаёт ещё что-то сам, и контейнер внезапно превращается в декорацию. Проблема не в new как таковом, а в том, что вы прячете сборку зависимостей внутрь прикладной логики. В итоге заменить реализацию, протестировать класс или просто понять архитектуру становится тяжело. Если зависимость важная, пусть она приходит через конструктор, а не рождается «внутри метода по настроению».

Ошибка №2: field injection как “стиль по умолчанию”.
@Autowired на поле выглядит соблазнительно: меньше строк, меньше возни. Но это как приклеить провод изолентой и сказать «ну работает же». Поле скрывает зависимость, объект можно создать в невалидном состоянии, и чтение класса превращается в игру “найди, что сюда кто-то тайно подставляет”. Constructor injection гораздо честнее: открыли класс — увидели зависимости — поняли, что он делает.

Ошибка №3: main-класс лежит в слишком узком пакете.
Это тот случай, когда всё «логично», но не работает. Вы положили CatalogServiceApplication в com.example.catalogservice.app, а пакеты catalog и config оказались рядом, а не внутри. Итог — Spring их просто не сканирует. Ошибка выглядит страшно, но лечится просто: поднимите main-класс в верхний пакет приложения и держите все прикладные пакеты внутри него.

Ошибка №4: один гигантский @Configuration на весь проект.
Сначала это удобно, потом — больно. Такой класс становится местом, где смешано всё: и прикладные бины, и support, и какие-то «потом разберёмся». В результате любая мелкая правка приводит к конфликтам, а чтение превращается в археологию. Разделяйте конфигурацию по темам: один класс — один смысл. Это не “архитектурный пафос”, это просто способ не утонуть в собственном проекте.

Ошибка №5: контроллер знает слишком много.
Даже если вы ещё не пишете web-методы, привычка “пусть контроллер сам всё делает” появляется быстро. Контроллер начинает лезть в репозиторий напрямую, фильтровать данные, нормализовывать slug и вообще жить полной жизнью. Проблема тут не в контроллере, а в направлении зависимостей и ответственности. Контроллер должен быть тонким: он зависит от сервиса, а сервис уже решает прикладные задачи.

1
Задача
Spring Boot, 7 уровень, 4 лекция
Недоступна
Регистрация helper-класса через `@Bean`
Регистрация helper-класса через `@Bean`
1
Задача
Spring Boot, 7 уровень, 4 лекция
Недоступна
Clean wiring для мини-каталога
Clean wiring для мини-каталога
1
Опрос
Spring зависимости, 7 уровень, 4 лекция
Недоступен
Spring зависимости
Внедрение и архитектурные слои
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ