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 лучше не стало — вы просто спрятали список зависимостей в один «мешок», и теперь снова нужно лезть внутрь, чтобы понять, что сервису нужно. Обычно лучше признать реальную сложность и постепенно дробить ответственность сервисов.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ