JavaRush /Курсы /Spring Boot /IoC и DI в Spring: управление объектами

IoC и DI в Spring: управление объектами

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

1. Java-картина: мир через new

В маленьких консольных приложениях и учебных проектах самый естественный путь — создавать нужные объекты прямо там, где они понадобились: через new. Это быстро и понятно, пока классов мало и мозг не перегружен. Проблема начинается позже: когда классов становится больше, а связи между ними — плотнее. Тогда невинное new внутри класса превращается в «скрытую проводку в стене»: пока всё работает — вы счастливы, а когда приходит время что-то менять, приходится ломать половину дома.

Посмотрим на простейший пример в стиле «так делает почти каждый начинающий». Есть репозиторий (источник данных) и сервис (логика поверх данных):

import java.util.List;

class InMemoryCourseCatalogRepository {
    List<String> findAllSlugs() {
        // Здесь репозиторий "делает вид", что ходит за данными,
        // хотя на самом деле возвращает захардкоженный список.
        return List.of("spring-boot", "spring-core");
    }
}

class CourseCatalogService {
    // Ключевая проблема: сервис сам создаёт зависимость.
    // Снаружи не видно, что ему вообще нужен репозиторий, и какой именно.
    private final InMemoryCourseCatalogRepository repository = new InMemoryCourseCatalogRepository();
}

Пока репозиторий здесь конкретный и in-memory. Это специально упрощённая стартовая точка: сначала важно увидеть саму проблему от new внутри класса.

На первый взгляд всё нормально. Но обратите внимание: зависимость repository спрятана внутри CourseCatalogService. Снаружи не видно, что сервису вообще нужен репозиторий, какой именно, можно ли его заменить и сколько таких репозиториев в итоге будет создано.

Это похоже на ситуацию, когда вы приходите в кафе, заказываете «кофе», а вам приносят кофе + случайный сироп + случайный размер стакана, потому что бариста решил «так лучше». Возможно, даже вкусно. Но вы же не это заказывали. И главное — вы не управляете выбором.

2. Скрытые зависимости — практическая боль

Пока проект маленький, скрытая зависимость кажется мелочью. Но по мере роста кода она начинает создавать вполне реальные инженерные проблемы. Самая неприятная из них — вы теряете контроль над тем, как устроено приложение как система: из каких частей оно состоит и как они соединены.

Представьте, что вы пишете catalog-service — наш учебный сервис каталога курсов. Сегодня данные лежат «в памяти», завтра — в конфигурации, послезавтра — в файле, а ещё позже понадобится заглушка для теста. Если CourseCatalogService сам делает new InMemoryCourseCatalogRepository(), у вас просто нет удобной точки, где можно сказать: «А сейчас сервис должен работать с другой реализацией репозитория».

Есть и более тонкая проблема: класс получает две ответственности вместо одной. Он не только делает «свою работу» (например, предоставляет операции каталога), но и занимается сборкой инфраструктуры вокруг себя: решает, какие именно объекты создавать, в каком порядке и с какими параметрами. В итоге в одном месте смешиваются логика и «сборка мира», и читать такой код становится тяжелее.

Чтобы разница была совсем наглядной, зафиксируем её в компактной таблице:

Что происходит Когда зависимость создаётся через new внутри класса Когда зависимость передаётся извне
Видно ли по коду, что классу нужна зависимость Не видно: зависимость спрятана в полях Видно сразу: в конструкторе
Можно ли легко заменить реализацию Почти нет: нужно править код класса Да: меняем сборку, класс не трогаем
Сколько экземпляров получится Часто «сколько угодно» и случайно Обычно «сколько надо» и осознанно
Удобно ли тестировать Больно: сложно подменять зависимости Проще: можно подставить заглушку

И здесь важно правильно понять: никто не запрещает new как явление природы. new прекрасен, когда вы создаёте простые объекты-данные или что-то локальное внутри метода. Проблема возникает, когда через new создаются важные компоненты системы — сервисы, репозитории, клиенты, обработчики, — которые должны быть заменяемыми и управляемыми.

3. IoC: «вызовут вас, а не вы»

IoC часто звучит как термин из архитектурных трактатов — тех самых, где очень любят диаграммы и не очень любят людей. Но сама идея простая: в обычной программе вы чувствуете себя главным. Вы пишете main, вызываете методы и управляете порядком действий. Во фреймворке «главный» — сам фреймворк. Ваш код становится набором компонентов, которые встраиваются в общий механизм, а уже этот механизм решает, когда и как вас вызвать.

Самая бытовая формула:

— библиотека — это когда вы вызываете код библиотеки;
— фреймворк — это когда код фреймворка вызывает ваш код.

Чтобы не привязываться к Spring, возьмём пример прямо из Java. Сортировка списка — это готовый алгоритм, почти «фреймворк в миниатюре», а Comparator — ваш «кусочек логики», который будет вызван внутри этого алгоритма:

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

// Мы готовим данные...
List<String> slugs = new ArrayList<>(List.of("spring-boot", "java"));

// ...и передаём "кусочек поведения" наружу: как сравнивать элементы.
// Важно: мы не управляем тем, сколько раз и когда вызовется comparator.
slugs.sort(Comparator.comparing(String::length));

System.out.println(slugs); // [java, spring-boot]

Ключевой момент здесь не в сортировке. Он в том, что вы не управляете тем, как именно идёт сортировка и когда именно вызывается сравнение. Вы просто отдаёте «кусочек поведения» (компаратор), а алгоритм вызывает его столько раз, сколько нужно. Это и есть маленькая модель IoC: «не я решаю, когда меня позовут; меня позовут внутри общего механизма».

Spring разворачивает ту же идею на уровне всего приложения. Вы не должны руками решать, «когда создать этот сервис», «в каком порядке создать эти компоненты», «как связать их зависимости». Вместо этого вы описываете компоненты, а система сборки — контейнер — берёт управление на себя.

4. DI: зависимости явными без лапши

DI — это практический способ реализовать IoC на уровне классов. Если IoC — это идея «управление не у меня», то DI — это техника «я не создаю зависимости внутри, мне их дают снаружи». Если по-человечески: класс не должен сам добывать себе всё необходимое, как герой RPG, который голышом выходит в лес и крафтит меч из палки. Класс должен честно сказать, что ему нужно, и получить это извне.

Самый простой и самый здоровый вариант DI — через конструктор. Тогда зависимости становятся частью «контракта» класса: если тебе нужен repository, так и напиши это в конструкторе.

Вот тот же сервис, но уже с DI:

class CourseCatalogService {
    // Теперь зависимость явно хранится в поле...
    private final InMemoryCourseCatalogRepository repository;

    CourseCatalogService(InMemoryCourseCatalogRepository repository) {
        // ...и явно передаётся снаружи.
        // Сервис не решает, КАКОЙ репозиторий использовать — он просто работает с тем, что ему дали.
        this.repository = repository;
    }
}

Это маленькое изменение даёт большой эффект. Теперь любой человек — и любой инструмент — видит: чтобы создать CourseCatalogService, нужен InMemoryCourseCatalogRepository. Это пока ещё конкретная зависимость, но она уже перестала быть скрытой. Сервис больше не собирает себе мир сам.

Давайте сделаем пример чуть живее, чтобы увидеть пользу. Добавим метод countCourses():

class CourseCatalogService {
    private final InMemoryCourseCatalogRepository repository;

    CourseCatalogService(InMemoryCourseCatalogRepository repository) {
        this.repository = repository;
    }

    int countCourses() {
        // Сервис занимается логикой поверх данных,
        // а не "добыванием" этих данных (созданием репозитория).
        return repository.findAllSlugs().size();
    }
}

Теперь сервис действительно использует зависимость, и сразу понятнее, зачем она ему нужна.

Пока сервис всё ещё привязан к конкретному in-memory классу. Чтобы заменить реализацию без изменения самого сервиса, нужен ещё один шаг — отделить контракт от реализации.

Важно: DI не требует Spring. DI — это стиль проектирования, который можно и нужно применять даже в обычной Java. Spring просто автоматизирует сборку зависимостей и снимает рутину, но сама идея остаётся той же.

5. Точка сборки: объектный граф в одном месте

Если классы перестали создавать зависимости сами, возникает логичный вопрос: «Окей, а кто тогда их создаёт?» Кто-то должен. Просто теперь это делается в одном месте, где видно всю картину. Это место часто называют composition root — точкой, где приложение «собирается» из деталей.

В простом приложении composition root — это ваш main():

public class CatalogApp {
    public static void main(String[] args) {
        // Точка сборки: здесь создаём компоненты и связываем их зависимости.
        InMemoryCourseCatalogRepository repository = new InMemoryCourseCatalogRepository();

        // Важно: сервис не делает new InMemoryCourseCatalogRepository() внутри себя.
        CourseCatalogService service = new CourseCatalogService(repository);

        System.out.println(service.countCourses()); // 2
    }
}

Здесь важно то, что цепочка зависимостей стала прозрачной. Видно, где и в каком порядке собираются объекты, а wiring больше не размазан по классам с new.

Но зависимость пока всё ещё конкретная. Чтобы по-настоящему показать заменяемость, введём маленькую абстракцию. Да, слово «интерфейс» у многих вызывает ощущение, что сейчас начнётся ООП-ритуал, но мы сделаем всё честно и по минимуму.

import java.util.List;

interface CourseCatalogRepository {
    // Контракт: "умей вернуть список курсов" (или их идентификаторов).
    List<String> findAllSlugs();
}

class InMemoryCourseCatalogRepository implements CourseCatalogRepository {
    public List<String> findAllSlugs() {
        // Реализация "в памяти" — удобно для учебного проекта и тестов.
        return List.of("spring-boot", "spring-core");
    }
}

Теперь наш сервис зависит не от конкретного класса, а от контракта:

class CourseCatalogService {
    // Теперь сервис зависит от интерфейса (контракта), а не от конкретной реализации.
    private final CourseCatalogRepository repository;

    CourseCatalogService(CourseCatalogRepository repository) {
        // Это и есть место, где "вкалывается" зависимость (injection).
        this.repository = repository;
    }

    int countCourses() {
        return repository.findAllSlugs().size();
    }
}

А в main() мы решаем, какую реализацию дать сервису:

public class CatalogApp {
    public static void main(String[] args) {
        // Решение "какая реализация нужна" принимается на уровне сборки приложения.
        CourseCatalogRepository repo = new InMemoryCourseCatalogRepository();

        // Сервису всё равно, что это именно InMemory — он видит только контракт.
        CourseCatalogService service = new CourseCatalogService(repo);

        System.out.println(service.countCourses()); // 2
    }
}

Вот здесь уже появляется вкус будущего Spring-подхода: приложение — это граф объектов, и этот граф можно собирать по-разному. Не потому что «так модно», а потому что так проще управлять сложностью.

Если хочется закрепить картину визуально, вот как выглядит наш мини-граф зависимостей; стрелка здесь означает «зависит от»:

flowchart LR
    Service[CourseCatalogService] --> Repo[CourseCatalogRepository]

С ростом проекта граф станет больше, но идея останется той же: зависимости направлены «вниз», а сборка — «сверху».

6. Когда Spring Boot кажется магией

Spring Boot сильно упрощает старт проекта: зависимости, автоконфигурация, запуск приложения — всё происходит быстро. Именно поэтому новичок легко попадает в ловушку: кажется, что достаточно написать пару аннотаций, а дальше «оно само». Но это «само» устроено не как фокус, а как реализация IoC/DI на уровне платформы.

Если не понимать IoC и DI, легко начать делать типичные вещи из мира «обычной Java», которые в Spring-мире ломают архитектуру изнутри. Например, можно написать класс, который внутри себя собирает всё через new, потому что «так привычно». Формально такой код даже может работать, но вы постепенно отключаете главную ценность Spring: управляемую сборку приложения.

Очень практическое правило, которое стоит запомнить ещё до Spring-аннотаций: важные компоненты приложения должны быть простыми Java-классами с явными зависимостями, а их создание должно происходить «снаружи». Вручную — это main(). В Spring — это контейнер. Но стиль проектирования одинаковый.

И вот здесь мы возвращаемся к названию лекции. Spring Boot нельзя нормально понять без IoC и DI не потому, что «так написано в учебнике», а потому, что Boot — это удобная оболочка вокруг системы, которая создаёт, хранит и связывает объекты. Если вы не понимаете, что значит «связывать объекты зависимостями», любое поведение Boot начинает выглядеть как: «ну оно как-то нашло мой класс», «ну оно как-то подставило зависимость», «ну оно как-то запустило сервер». А нам нужна спокойная инженерная модель: «объекты создаются и соединяются по правилам, а не по удаче».

В нашем catalog-service это особенно важно, потому что проект специально сделан маленьким и без базы данных: мы не хотим отвлекаться на JPA или Security. Нам нужно увидеть скелет приложения: как сервис зависит от репозитория, как контроллер (позже) зависит от сервиса, как конфигурация (позже) управляет поведением. И весь этот скелет держится на DI.

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

В начале пути ошибки почти всегда одинаковые, потому что мышление ещё «консольное»: есть main, есть new, есть «погнали». Это нормально, но важно быстро научиться замечать, где такой подход начинает мешать. Ошибки ниже не про «вы плохой разработчик», а про то, где именно ломается управляемость кода, когда проект перестаёт помещаться в голову.

Ошибка №1: путать IoC и DI и считать, что это одно и то же.
IoC — это принцип «управление не внутри вашего кода, а снаружи». DI — это конкретная техника «зависимости передаются извне». Когда вы говорите «у нас IoC, потому что мы передаём зависимость в конструктор», вы на самом деле описываете DI. IoC — шире: он про то, кто управляет жизнью компонентов и кто кого вызывает.

Ошибка №2: думать, что DI — это просто конструктор с одним параметром.
Начинающий часто считает, что само наличие конструктора с параметром делает код «красивым и правильным». Но DI — не косметика. DI полезен, когда вы реально переносите создание зависимостей наружу и перестаёте прятать их внутри класса. Если вы оставили new внутри, а сверху просто добавили конструктор «для вида», вы не получили преимуществ — вы получили только дополнительный шум.

Ошибка №3: объявить DI, но всё равно создавать зависимости внутри класса «на всякий случай».
Очень популярный гибрид: часть зависимостей приходит в конструктор, а часть создаётся через new, потому что «так проще». Это делает поведение класса непредсказуемым: снаружи кажется, что зависимость можно заменить, а внутри она всё равно фиксирована. Если зависимость важная — либо она целиком внешняя, либо честно признаём, что это внутренняя деталь и она не должна быть компонентом системы.

Ошибка №4: превращать DI в религию интерфейсов «на всё подряд».
Интерфейс полезен, когда у вас есть реальная причина для заменяемости — например, разные источники данных или заглушка для тестов. Но интерфейс ради интерфейса — это просто дополнительный слой слов. В маленьком проекте можно начинать с конкретных классов, а интерфейсы добавлять там, где вы реально чувствуете: «мне нужно подменять реализацию».

Ошибка №5: делать конструктор огромным и не замечать, что класс стал «комбайном».
DI делает зависимости явными. Это прекрасно… пока вы вдруг не обнаружили конструктор на 10 параметров. Обычно это симптом того, что класс делает слишком много и пора разделить ответственность. DI тут как рентген: проблему не создаёт, но честно её показывает.

1
Задача
Spring Boot, 2 уровень, 0 лекция
Недоступна
Явная зависимость через конструктор
Явная зависимость через конструктор
1
Задача
Spring Boot, 2 уровень, 0 лекция
Недоступна
Ручная сборка графа объектов в `main`
Ручная сборка графа объектов в `main`
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ