JavaRush /Курсы /Spring Core /Dependency Injection ( DI

Dependency Injection ( DI)

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

1. От IoC к DI: как идея превращается в привычку кода

После лекции про IoC обычно остаётся простой вопрос: «Окей, зависимости внутри сервиса не создаём. А как делать это правильно и одинаково во всех классах?» Здесь и появляется DI. Это набор практик, который превращает инверсию управления не в лозунг, а в нормальный стиль проектирования. Почти как «мой руки перед едой», только для кода.

  • IoC отвечает на вопрос: «кто управляет созданием и выбором зависимостей?» — и мы уже договорились: не бизнес-класс.
  • DI отвечает на вопрос: «как бизнес-класс получит то, что ему нужно, чтобы работать?» — и ответ: через явную границу класса.

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

Удобная мини-таблица, чтобы уложить это в голове:

Понятие На человеческом В коде это выглядит как
IoC «Класс не решает, что именно создавать» new уезжает из бизнес-методов
DI «Класс явно получает готовые зависимости» зависимости приходят через конструктор (или другой явный канал)

И да: DI — это не «паттерн для взрослых дядей». Это способ сделать класс честным. А честность в коде — вещь очень практичная: она экономит время и нервы, особенно когда проект перестаёт помещаться в голове целиком (а это случается неожиданно быстро).

2. Зависимости и входные данные

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

Входные данные — это то, что меняется от вызова к вызову. Например, данные нового заказа.
Зависимость — это, грубо говоря, сотрудник класса: объект, который помогает ему выполнять работу постоянно и обычно используется во многих методах.

Сравним:

Признак Входные данные метода Зависимость класса
Живёт сколько? один вызов метода обычно столько же, сколько живёт объект сервиса
Меняется как часто? почти всегда редко (обычно только при сборке приложения)
«Кто виноват» в выборе? вызывающий код точка сборки приложения
Пример в ContextFlow CreateOrderCommand OrderStore, AuditWriter, NotificationSender

Возьмём снова не весь ContextFlow, а только тот кусок, где это различие видно сразу: что сервису нужно как постоянный инструмент, а что приходит только на один вызов.

Посмотрим на маленький фрагмент по делу. Команда создания заказа — это входные данные:


class CreateOrderCommand {
    // Входные данные конкретного вызова (не зависимость сервиса)
    final int total;

    CreateOrderCommand(int total) {
        this.total = total;
    }
}

А вот зависимости — это то, что сервис будет использовать как инструменты:


interface OrderStore {
    // Роль: долговременное хранилище, куда сервис сохраняет заказ
    void save(String orderId);
}

interface AuditWriter {
    // Роль: аудит (логирование действий) — реализация может быть любой
    void write(String message);
}

Теперь ключевой момент: команда не должна становиться зависимостью. Её не надо хранить в поле, потому что она относится к одному вызову. А вот OrderStore и AuditWriter — как раз зависимости, потому что сервис будет использовать их постоянно.

Нормальная форма метода в сервисе выглядит так: входные данные — параметрами, зависимости — полями (и приходят через конструктор).


class OrderPlacementService {
    // Зависимости сервиса: приходят извне и живут вместе с сервисом
    private final OrderStore store;
    private final AuditWriter auditWriter;

    OrderPlacementService(OrderStore store, AuditWriter auditWriter) {
        this.store = store;
        this.auditWriter = auditWriter;
    }

    void place(CreateOrderCommand command) {
        // Входные данные — параметром; зависимости — используем из полей
        store.save("ORD-1");
        auditWriter.write("created, total=" + command.total);
    }
}

Да, тут ORD-1 пока «захардкожен» — это нормально для примера. Мы сейчас не про генерацию id, а про то, как отличить «то, что приходит на вход» от «того, без чего сервис жить не может».

Если хотите одну короткую эвристику: если объект нужен “всегда и везде” для работы сервиса — это зависимость; если он описывает конкретный вызов — это входные данные.

3. Скрытые зависимости и new в сервисе

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

Вот пример, который выглядит «коротко и удобно», но на самом деле прячет важное:


class OrderPlacementService {
    void place(int total) {
        // Скрытая зависимость: сервис сам выбирает реализацию и сам создаёт её
        AuditWriter auditWriter = new ConsoleAuditWriter();
        auditWriter.write("created, total=" + total);
    }
}

class ConsoleAuditWriter implements AuditWriter {
    public void write(String message) {
        // Побочный эффект (консоль) «вшит» в реализацию
        System.out.println("[AUDIT] " + message); // [AUDIT] created, total=1000
    }
}

Кажется: «Ну и что? Всего-то один new». Проблема не в количестве new, а в том, что сервис сам принимает решение, какой аудит писать. Сегодня это консоль, завтра файл, послезавтра вообще отключённый аудит в тестовом режиме. И каждый раз вам придётся лезть внутрь OrderPlacementService, хотя по смыслу бизнес-сценарий не менялся.

Вторая проблема — «невидимость» контракта. Снаружи класс выглядит так, будто он просто умеет разместить заказ, и всё. Но на деле он ещё и «знает», как устроен аудит. Для бизнес-сервиса это лишнее знание. Оно делает класс более хрупким.

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

Частая самообманная версия той же проблемы — «я же вынес new в приватный метод, значит всё ок»:


class OrderPlacementService {
    void place(String orderId) {
        // Зависимость всё равно создаётся внутри сервиса (просто глубже спрятана)
        auditWriter().write("created " + orderId);
    }

    private AuditWriter auditWriter() {
        // Скрытая политика выбора реализации: снаружи её не видно и не заменить
        return new ConsoleAuditWriter();
    }
}

Нет, не всё ок. Это всё ещё скрытая зависимость. Точка управления не переехала наружу — она просто спряталась глубже.

DI начинается там, где класс перестаёт создавать своих помощников и начинает их получать. И в этот момент конструктор становится не «скучной штукой для инициализации», а честной декларацией того, без чего сервис не работает.

4. Конструктор как контракт: constructor injection в plain Java

Слово «контракт» в программировании звучит так, будто сейчас вам принесут бумаги на подпись, а потом за вами пойдёт юридический отдел. Но в ООП всё проще: контракт класса — это то, что можно понять о нём, не залезая внутрь методов. И конструктор — идеальное место, чтобы этот контракт был виден сразу.

Сделаем DI в том виде, который станет нашей базовой привычкой: constructor injection. То есть зависимости приходят в конструктор, сохраняются в final поля, и дальше сервис спокойно работает.

Минимальная версия для ContextFlow:


class OrderPlacementService {
    // Все ключевые зависимости объявлены явно: контракт видно по конструктору
    private final OrderStore store;
    private final AuditWriter auditWriter;

    OrderPlacementService(OrderStore store, AuditWriter auditWriter) {
        this.store = store;
        this.auditWriter = auditWriter;
    }

    void place(String orderId) {
        // Бизнес-логика использует роли, но не знает про конкретные реализации
        store.save(orderId);
        auditWriter.write("created " + orderId);
    }
}

Почему final — это важно? Потому что так вы гарантируете: если объект создан, значит он уже в корректном состоянии. У него есть всё, что нужно. Никаких «полусобранных» сервисов. В Java это особенно приятно, потому что иначе легко получить объект, который «вроде существует», но падает с NullPointerException при первом же вызове.

Чтобы пример был живым, добавим простейшую реализацию OrderStore:


class InMemoryOrderStore implements OrderStore {
    public void save(String orderId) {
        // Пример реализации для демонстрации: сохраняем «в никуда», печатаем в консоль
        System.out.println("saved " + orderId); // saved ORD-1
    }
}

Теперь вопрос: кто создаёт InMemoryOrderStore и ConsoleAuditWriter? Ответ: кто-то снаружи. Обычно это точка входа приложения. Да, мы пока не называем это composition root (это следующая лекция), но минимальный пример выглядит так:


class Main {
    public static void main(String[] args) {
        // Точка сборки: здесь выбираем конкретные реализации зависимостей
        OrderStore store = new InMemoryOrderStore();
        AuditWriter audit = new ConsoleAuditWriter();

        // Внедрение зависимостей в конструктор
        OrderPlacementService service = new OrderPlacementService(store, audit);
        service.place("ORD-1");
    }
}

И вот здесь впервые появляется ощущение «ага, DI работает»: сервис чистый, внутри него нет выбора реализаций, нет new, нет «мне виднее». Он просто делает сценарий.

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

flowchart LR
    Main["Main (точка входа)"] --> OPS["OrderPlacementService"]
    Main --> Store["InMemoryOrderStore"]
    Main --> Audit["ConsoleAuditWriter"]

    OPS -->|uses| Store
    OPS -->|uses| Audit

Пока проект маленький, это может казаться «лишним». Но именно эта дисциплина даёт вам контроль, когда проект начинает расти: бизнес-классы перестают быть местом, где «всё создаётся», и становятся местом, где «всё используется».

5. Подмена поведения без переписывания сервиса

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

Сделаем две реализации AuditWriter. Первая пишет в консоль:


class ConsoleAuditWriter implements AuditWriter {
    public void write(String message) {
        System.out.println("[AUDIT] " + message);
    }
}

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


class NoOpAuditWriter implements AuditWriter {
    public void write(String message) {
        // Ничего не делаем: «тихий» аудит (полезно для тестов/демо)
    }
}

Теперь бизнес-сервис не меняется вообще. Меняется только то, что мы ему передали:


class Main {
    public static void main(String[] args) {
        OrderStore store = new InMemoryOrderStore();

        OrderPlacementService loud =
                new OrderPlacementService(store, new ConsoleAuditWriter());

        OrderPlacementService quiet =
                new OrderPlacementService(store, new NoOpAuditWriter());

        loud.place("ORD-1");  // будет аудит
        quiet.place("ORD-2"); // аудита не будет
    }
}

Вот это и есть «вкус DI»: поведение переключается на уровне сборки, а не на уровне переписывания бизнес-кода.

И тут важно не скатиться в крайность «DI = надо обязательно сделать интерфейс на всё». Мы используем интерфейсы ровно там, где это естественно, например когда аудит выступает как отдельная роль. Здесь достаточно зафиксировать одну мысль: DI особенно хорошо работает, когда сервис зависит от роли, а не от конкретного класса.

6. DI и тестируемость без фреймворков

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

С DI вы можете передать в сервис фейковую реализацию зависимости, которая ничего не делает или, наоборот, записывает результаты в память, чтобы потом проверить эффект.

Сделаем RecordingAuditWriter, который сохраняет сообщения в список:


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

class RecordingAuditWriter implements AuditWriter {
    // Храним записи в памяти, чтобы потом проверить их в тесте
    private final List<String> records = new ArrayList<>();

    public void write(String message) {
        // Вместо реального аудита — просто запоминаем сообщение
        records.add(message);
    }

    List<String> records() {
        // Достаём записи для проверок
        return records;
    }
}

Теперь можно «протестировать» сценарий даже без JUnit, просто в обычном main (это не «правильные тесты», но как учебная лаборатория — отлично):


class Main {
    public static void main(String[] args) {
        RecordingAuditWriter audit = new RecordingAuditWriter();
        OrderPlacementService service =
                new OrderPlacementService(new InMemoryOrderStore(), audit);

        service.place("ORD-42");

        System.out.println(audit.records().size()); // 1
        System.out.println(audit.records().get(0)); // created ORD-42
    }
}

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

Это же работает и для уведомлений. Например, сделаем NotificationSender, который вместо реальной отправки просто сохраняет последний orderId:


class RecordingNotificationSender implements NotificationSender {
    private String lastOrderId;

    public void send(String orderId) { lastOrderId = orderId; }

    String lastOrderId() { return lastOrderId; }
}

И вы сможете проверить, что уведомление действительно «было отправлено» в учебном смысле — без SMS, e-mail и всего остального, что нам сейчас в проекте не нужно.

Важно заметить тонкий момент: DI повышает тестируемость не потому, что «мы стали ближе к тестам», а потому что перестали прятать зависимости. А когда зависимости честные, тесты становятся просто ещё одним способом собрать объект — таким же, как и обычный запуск приложения.

7. Когда конструктор раздувается: что это говорит о дизайне

DI делает одну очень честную вещь: вытаскивает наружу список того, что ваш класс реально использует. И иногда этот список выглядит… пугающе. Вчера сервис казался «простым», а сегодня у него в конструкторе пять параметров, и вы начинаете подозревать, что класс тайно управляет космической станцией.

Например, в ContextFlow OrderPlacementService вполне может честно зависеть от нескольких ролей:


class OrderPlacementService {
    OrderPlacementService(OrderStore store,
                          DiscountPolicy discountPolicy,
                          NotificationSender sender,
                          AuditWriter auditWriter,
                          OrderIdGenerator idGenerator) {
        // ...
    }
}

Пять зависимостей уже заметны, но это не катастрофа. Для сервиса-оркестратора, который действительно координирует сценарий, это вполне типично: сгенерировать id, сохранить заказ, рассчитать скидку, уведомить, записать аудит.

Но важная мысль здесь такая: длинный конструктор — не повод отказаться от DI. Это повод спросить себя: «А не делаю ли я в одном классе слишком много разнородных задач?» DI не создаёт проблему, он её показывает. Раньше зависимости прятались в new внутри методов, и вы просто не видели реальный размер ответственности класса.

Что обычно делают, когда конструктор становится слишком длинным? Не «упаковывают всё в один супер-объект AppDependencies» — это часто просто маскировка, — а постепенно выделяют более узкие сервисы. Например, отдельный сервис для аудита, отдельный — для отправки уведомлений, отдельный — для расчёта цены. Уже сейчас полезно видеть: DI подталкивает к более аккуратному дизайну не потому, что «так надо», а потому, что иначе код становится трудно читать.

8. Типичные ошибки при использовании DI в plain Java

На этом этапе очень легко сделать пару «почти правильных» шагов и получить код, который выглядит как DI, а ведёт себя как старый добрый new вручную. Эти ошибки нормальны — вы как раз формируете привычку. Главное, чтобы они не превратились в стиль «так у нас принято», потому что потом больно отучиваться.

Ошибка №1: называть DI любую передачу параметра в метод.
Если вы передали orderId или CreateOrderCommand в place(...), это не DI, это просто входные данные. DI начинается там, где вы перестаёте создавать долгоживущие зависимости внутри сервиса и начинаете получать их извне. Иначе вы начнёте видеть DI везде — и перестанете замечать его там, где он реально нужен.

Ошибка №2: пустой конструктор + зависимости, созданные в полях.
Иногда хочется оставить «красивый» конструктор без параметров и написать так: private final AuditWriter auditWriter = new ConsoleAuditWriter(). Внешне класс выглядит простым, но зависимости снова становятся скрытыми. Вы опять жёстко привязали сервис к конкретным реализациям и лишили себя возможности подмены.

Ошибка №3: внедрили зависимость… и всё равно сделали new внутри метода.
Бывает так: часть зависимостей вы уже передаёте через конструктор, но в одном методе всё равно создаёте «маленькую штучку» через new, потому что «ну она же маленькая». Проблема в том, что эта «маленькая штучка» завтра становится «важной штучкой», и DI в классе превращается в смесь стилей. Контракт класса мутнеет: часть поведения зависит от сборки, часть — зашита внутрь.

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

Ошибка №5: лечить длинный конструктор маскировкой вместо рефакторинга.
Типичный трюк: создать класс ContextFlowDependencies и передавать его одним параметром, а внутри хранить всё подряд. Да, конструктор стал короче. Но DI лучше не стало — вы просто спрятали список зависимостей в один «мешок», и теперь снова нужно лезть внутрь, чтобы понять, что сервису нужно. Обычно лучше признать реальную сложность и постепенно дробить ответственность сервисов.

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