1. Введение
Сейчас соберём в одну картину роли, constructor injection и ручной composition root и получим целостную plain Java-версию ContextFlow — без Spring, но уже без скрытых зависимостей. Сервисы здесь получают зависимости снаружи, а весь граф объектов наконец виден целиком.
В результате у нас получится версия, которую уже приятно читать: открыл OrderPlacementService — увидел сценарий, а не зоопарк из new. Открыл Main — увидел, как именно приложение собрано и какими реализациями оно «дышит».
Чтобы не распыляться на структуру пакетов, в этой лекции все классы пока живут в одном пакете com.example.contextflow. Да, архитекторы в этот момент слегка вздрагивают, но сейчас нам важнее увидеть саму сборку приложения, чем спорить о раскладке файлов.
2. Данные сценария и зависимости сервиса
Чтобы в этой версии не было путаницы, держим одно правило: в конструктор идут только инструменты сервиса, а данные конкретного заказа приходят в метод. Поэтому OrderStore, AuditWriter, NotificationSender, DiscountPolicy и OrderIdGenerator живут как зависимости, а Customer, CreateOrderCommand и сам Order описывают один конкретный запуск сценария.
Деньги здесь нарочно упрощены до int: сейчас нам важно собрать прозрачный workflow plain Java-приложения, а не уходить в отдельную тему про денежную модель.
Доменные объекты
package com.example.contextflow;
// Уровень лояльности клиента, влияет на скидку (доменные данные, НЕ зависимость)
public enum LoyaltyLevel {
STANDARD,
GOLD
}
package com.example.contextflow;
// Клиент — это входные данные сценария (создаём на запуск сценария, не инжектим как сервис)
public record Customer(String id, String name, LoyaltyLevel loyaltyLevel) {
}
package com.example.contextflow;
// Команда (DTO) для сценария создания заказа: "ингредиенты" конкретного запуска
public record CreateOrderCommand(Customer customer, int totalAmount) {
}
package com.example.contextflow;
// Статус заказа — часть доменной модели
public enum OrderStatus {
NEW,
CANCELLED
}
package com.example.contextflow;
// Заказ — доменный объект; его создание внутри сценария нормально
public record Order(String id, Customer customer, int totalAmount, OrderStatus status) {
}
Обратите внимание на важную мысль: Customer, CreateOrderCommand и Order — это не зависимости, а данные. Они появляются «на один сценарий» и обычно создаются там, где мы запускаем сценарий, — в раннере или в Main, — а не живут как «глобальные сервисы».
3. Контракты зависимостей: интерфейсы как роли приложения
Теперь зафиксируем роли, которые нужны этой версии проекта. Здесь интерфейсы работают как честные контракты: через них сервис получает хранение, скидки, уведомления, аудит и генерацию id.
Небольшая таблица-карта:
| Роль (что нужно бизнесу) | Интерфейс | Пример реализации (пока) |
|---|---|---|
| хранить заказ | OrderStore | InMemoryOrderStore |
| считать скидку | DiscountPolicy | NoDiscountPolicy |
| отправлять уведомления | NotificationSender | ConsoleNotificationSender |
| писать аудит | AuditWriter | ConsoleAuditWriter |
| генерировать id | OrderIdGenerator | DeterministicOrderIdGenerator |
Теперь сами интерфейсы. Попробуйте читать их как «словарь возможностей», а не как «кусок кода».
package com.example.contextflow;
import java.util.Optional;
// Роль: хранилище заказов (как именно хранить — не важно бизнес-сервису)
public interface OrderStore {
void save(Order order);
Optional<Order> findById(String orderId);
}
package com.example.contextflow;
// Роль: политика скидок (алгоритм можно менять без переписывания сценария)
public interface DiscountPolicy {
int discountFor(Customer customer, int totalAmount);
}
package com.example.contextflow;
// Роль: отправка уведомлений (консоль, SMS, почта — не важно)
public interface NotificationSender {
void sendOrderCreated(Order order);
}
package com.example.contextflow;
// Роль: аудит — запись технических/бизнес-событий (в консоль, файл, шину и т.д.)
public interface AuditWriter {
void write(String message);
}
package com.example.contextflow;
// Роль: генерация идентификаторов заказа (детерминированная, UUID и т.д.)
public interface OrderIdGenerator {
String nextId();
}
Названия здесь говорят о роли, а не о технологии: OrderStore, AuditWriter, NotificationSender. Конкретика вроде InMemory, Console или NoOp живёт уже в реализациях.
4. Реализации зависимостей
Теперь нужны первые реализации, чтобы проект реально запускался. Они специально максимально простые: память, консоль и предсказуемые правила. Наша цель не «продакшен», а показать, что каждую роль уже можно заменить без правки сценария.
Покажу несколько реализаций. Они специально «простые как табуретка»: наша цель — архитектурная сборка, а не промышленный транспортный самолёт.
Хранилище: InMemoryOrderStore
package com.example.contextflow;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
// Простейшая реализация хранилища: держим всё в памяти процесса
public class InMemoryOrderStore implements OrderStore {
// "База данных" на HashMap — годится для демо и тестов
private final Map<String, Order> orders = new HashMap<>();
@Override
public void save(Order order) {
// Ключ — id заказа, значение — сам заказ
orders.put(order.id(), order);
}
@Override
public Optional<Order> findById(String orderId) {
// Optional явно показывает "может не найтись"
return Optional.ofNullable(orders.get(orderId));
}
}
Да, методы тут почти в одну строку. В реальном проекте вы бы форматировали аккуратнее. Сейчас я просто экономлю место и держу фокус на идее: есть роль хранения, есть реализация, и бизнес-код от конкретной реализации не зависит.
Уведомления: ConsoleNotificationSender
package com.example.contextflow;
// Уведомления "в консоль" — дешёвая инфраструктура, легко заменить на реальную
public class ConsoleNotificationSender implements NotificationSender {
@Override
public void sendOrderCreated(Order order) {
// В реальном мире тут был бы email/SMS/push и т.п.
System.out.println("[NOTIFY] Order created: " + order.id()); // [NOTIFY] Order created: ORD-1
}
}
Аудит: ConsoleAuditWriter и NoOpAuditWriter
package com.example.contextflow;
// Аудит "в консоль" — как логирование, только в учебном виде
public class ConsoleAuditWriter implements AuditWriter {
@Override
public void write(String message) {
// В реальном мире это мог бы быть логгер или аудит-сервис
System.out.println("[AUDIT] " + message); // [AUDIT] created ORD-1, finalTotal=900
}
}
package com.example.contextflow;
// "Пустая" реализация: аудит как концепция есть, но конкретно здесь ничего не делаем
public class NoOpAuditWriter implements AuditWriter {
@Override
public void write(String message) {
// молча игнорируем — удобно для тихого режима и будущих тестов
// важно: бизнес-сервис всё равно вызывает аудит, но инфраструктура выбрана "нулевая"
}
}
NoOp-реализация — это не «обман». Это вполне честная стратегия: иногда бизнесу нужен «аудит как концепция», но в конкретном запуске мы не хотим выводить ничего — например, в учебном демо, где вывод и так шумный.
Скидки: NoDiscountPolicy и LoyalCustomerDiscountPolicy
package com.example.contextflow;
// Политика "никаких скидок" — полезна как дефолт и для тестов
public class NoDiscountPolicy implements DiscountPolicy {
@Override
public int discountFor(Customer customer, int totalAmount) {
// Скидка отсутствует независимо от суммы и клиента
return 0;
}
}
package com.example.contextflow;
// Политика: GOLD получает скидку, остальные — нет
public class LoyalCustomerDiscountPolicy implements DiscountPolicy {
@Override
public int discountFor(Customer customer, int totalAmount) {
// Условие по уровню лояльности — часть бизнес-правила (вынесена в отдельную роль)
// Скидка: 10% от суммы, но не больше 200
return customer.loyaltyLevel() == LoyaltyLevel.GOLD ? Math.min(200, totalAmount / 10) : 0;
}
}
Тут мы нарочно держим логику простой: золотому клиенту скидка 10%, но не больше 200. Это учебный пример, но он уже показывает важное: скидка — отдельная роль, и её можно менять, не переписывая сервис.
Генерация id: детерминированная и UUID
package com.example.contextflow;
// Детерминированный генератор: удобно для повторяемых запусков и понятных логов
public class DeterministicOrderIdGenerator implements OrderIdGenerator {
// Счётчик внутри процесса (в проде так делать опасно, но для демо — идеально)
private long seq = 0;
@Override
public String nextId() {
seq++;
// Читабельный id для вывода: ORD-1, ORD-2, ...
return "ORD-" + seq;
}
}
package com.example.contextflow;
import java.util.UUID;
// UUID-генерация — более "похожа на жизнь", когда не хочется коллизий между запусками
public class UuidOrderIdGenerator implements OrderIdGenerator {
@Override
public String nextId() {
// UUID генерируется библиотекой, бизнес-сервису всё равно как именно
return UUID.randomUUID().toString();
}
}
Две реализации генератора здесь нужны не ради коллекции генераторов, а чтобы было видно: сервису без разницы, откуда приходит id, пока у роли есть nextId().
5. OrderPlacementService: сценарий без скрытых new
Вот здесь — центральная точка, ради которой мы всё это затевали. Мы хотим открыть класс бизнес-сервиса и увидеть сценарий: «взять входные данные → посчитать скидку → собрать заказ → сохранить → отправить уведомление → записать аудит». Мы не хотим видеть «создать Map, создать writer, создать sender…» — это не сценарий, а строительная площадка.
Конструктор сервиса — это контракт: что нужно, чтобы этот сервис вообще мог работать. Сама бизнес-логика — в методе. И заметьте важную вещь: внутри метода мы можем создавать доменные объекты (Order) — это нормально. Но мы не создаём зависимости (OrderStore, AuditWriter и т.д.).
package com.example.contextflow;
// Бизнес-сервис: содержит сценарий, но не содержит "wiring" (никаких new зависимостей внутри)
public class OrderPlacementService {
private final OrderStore store;
private final DiscountPolicy discountPolicy;
private final NotificationSender notificationSender;
private final AuditWriter auditWriter;
private final OrderIdGenerator idGenerator;
public OrderPlacementService(OrderStore store,
DiscountPolicy discountPolicy,
NotificationSender notificationSender,
AuditWriter auditWriter,
OrderIdGenerator idGenerator) {
// DI: зависимости приходят снаружи, их легко подменять
this.store = store;
this.discountPolicy = discountPolicy;
this.notificationSender = notificationSender;
this.auditWriter = auditWriter;
this.idGenerator = idGenerator;
}
public Order place(CreateOrderCommand command) {
// 1) Генерируем id — это роль, а не "UUID прямо здесь"
String id = idGenerator.nextId();
// 2) Считаем скидку через политику (вынесенное бизнес-правило)
int discount = discountPolicy.discountFor(command.customer(), command.totalAmount());
int finalTotal = command.totalAmount() - discount;
// 3) Создаём доменный объект (это нормально создавать внутри сценария)
Order order = new Order(id, command.customer(), finalTotal, OrderStatus.NEW);
// 4) Побочные эффекты сценария делаем через роли-зависимости
store.save(order);
notificationSender.sendOrderCreated(order);
auditWriter.write("created " + id + ", finalTotal=" + finalTotal);
// 5) Возвращаем результат сценария
return order;
}
}
Если сейчас сравнить этот сервис с версией из первого дня, где внутри метода жили new, эффект виден сразу: сервис стал про заказ, а не про то, как собрать себе инструменты.
6. Composition root: сборка приложения
Теперь просто соберём все роли в одну точку. Wiring живёт в одном месте, а сервисы остаются чистыми.
Пусть этот код будет скучным и даже чуть длинным — именно в этом его польза. Сразу видно, какие реализации используются и где приложение собрано.
Схематично наш граф сейчас выглядит так:
flowchart LR
Main["Main (composition root)"] --> OPS["OrderPlacementService"]
OPS --> Store["OrderStore"]
OPS --> Disc["DiscountPolicy"]
OPS --> Notif["NotificationSender"]
OPS --> Audit["AuditWriter"]
OPS --> IdGen["OrderIdGenerator"]
А теперь — сам Main. Я добавлю ещё ScenarioRunner, чтобы Main не превращался одновременно и в точку сборки, и в запуск сценария. Main пусть собирает, раннер — запускает.
package com.example.contextflow;
// Composition root: единственная точка, где мы выбираем конкретные реализации и "собираем" граф
public class Main {
public static void main(String[] args) {
// Выбор реализаций (инфраструктура)
OrderStore store = new InMemoryOrderStore();
DiscountPolicy discountPolicy = new LoyalCustomerDiscountPolicy();
NotificationSender sender = new ConsoleNotificationSender();
AuditWriter auditWriter = new ConsoleAuditWriter();
OrderIdGenerator idGenerator = new DeterministicOrderIdGenerator();
// Сборка бизнес-сервиса из ролей
OrderPlacementService orderPlacementService =
new OrderPlacementService(store, discountPolicy, sender, auditWriter, idGenerator);
// Запуск сценария вынесен в отдельный класс, чтобы Main не стал "вторым бизнес-слоем"
ScenarioRunner runner = new ScenarioRunner(orderPlacementService);
runner.run();
}
}
Именно здесь особенно честно видно цену ручной сборки: Main быстро растёт. Но это всё равно лучше, чем new внутри сервиса, потому что весь wiring остаётся в одной точке.
Чтобы почувствовать заменяемость реализаций, попробуйте прямо здесь поменять одну строку:
AuditWriter auditWriter = new NoOpAuditWriter();
И вы сразу увидите: бизнес-сервис не изменился ни на символ, а поведение приложения — изменилось.
7. ScenarioRunner: запуск сценария без знания о сборке
Чтобы Main не превращался во второй бизнес-слой, сам запуск сценария вынесем в маленький ScenarioRunner. Он создаёт входные данные и вызывает уже собранный сервис, но не знает деталей wiring.
package com.example.contextflow;
// Раннер: создаёт входные данные сценария и вызывает бизнес-сервис
public class ScenarioRunner {
private final OrderPlacementService orderPlacementService;
public ScenarioRunner(OrderPlacementService orderPlacementService) {
// В раннер мы передаём уже собранный сервис (раннер не знает, как он собран)
this.orderPlacementService = orderPlacementService;
}
public void run() {
// Входные данные сценария создаются здесь, а не в DI-контейнере
Customer customer = new Customer("C-1", "Алиса", LoyaltyLevel.GOLD);
CreateOrderCommand cmd = new CreateOrderCommand(customer, 1000);
// Запуск бизнес-сценария
Order order = orderPlacementService.place(cmd);
// Вывод результата (UI/консоль — это не бизнес-логика)
System.out.println("Created order: " + order.id()); // Created order: ORD-1
}
}
Если запустить приложение в таком виде, вы получите примерно такой поток вывода:
[NOTIFY] Order created: ORD-1
[AUDIT] created ORD-1, finalTotal=900
Created order: ORD-1
Скидка сработала (1000 → 900), уведомление отправилось, аудит записался — и всё это без единого new внутри OrderPlacementService.
8. Что улучшилось и цена ручного wiring
На этом этапе легко поймать странное, но честное ощущение: кода стало больше. Это нормальная цена за явность. Зато теперь не нужно гадать, откуда у сервиса берутся зависимости и почему он внезапно меняет поведение.
| Что сравниваем | Было (скрытые new) | Стало (DI + composition root) |
|---|---|---|
| Где создаются зависимости | внутри бизнес-классов, размазано по коду | в одном месте (Main) |
| Можно ли заменить реализацию | обычно только правкой бизнес-кода | одной строкой в composition root |
| Читаемость сервиса | сценарий смешан со сборкой | сценарий читается как сценарий |
| Тестируемость | тяжело подменять зависимости | зависимости можно подставлять явно |
| Ошибки конфигурации | всплывают где-то уже при выполнении | чаще видно сразу при сборке |
Но есть и честный минус: composition root начинает пухнуть. В маленьком проекте это терпимо, но второй сценарий, третий сервис и ещё пара реализаций — и Main быстро превращается в длинный список «создай то, создай это». Это не повод возвращаться к new внутри сервиса. Это просто граница ручной сборки.
Ровно здесь и становится понятна ценность ApplicationContext. Граф зависимостей нам уже знаком: те же роли, те же конструкторы, те же сервисы. Контейнер нужен не затем, чтобы перепридумать OrderPlacementService, а затем, чтобы хранить правила сборки, создавать и переиспользовать объекты и останавливать приложение понятной ошибкой, если wiring сломан.
То есть ручной wiring уже сделал главное: сделал граф приложения видимым и показал, какую часть этой рутины дальше разумно отдать контейнеру.
9. Типичные ошибки в DI-friendly plain Java версии
В конце дня полезно зафиксировать грабли, на которые наступают почти все, кто впервые делает DI-рефакторинг. Тут нет «стыдных» ошибок: это те самые места, где мозг пытается вернуть старые привычки, потому что так было быстрее написать. Но наша цель — сделать код расширяемым и читаемым, а не победить в чемпионате по количеству new за минуту.
Ошибка №1: вынесли new из метода, но спрятали его в приватный метод того же класса.
Если OrderPlacementService больше не делает new прямо в place(), но делает new в private AuditWriter auditWriter() — вы не сделали IoC. Вы просто спрятали wiring глубже, как кот прячет тапок под диван: тапок всё равно пропал, но легче не стало.
Ошибка №2: зависимости передаются, но сервис всё равно иногда создаёт себе альтернативу через new.
Например, вы передали AuditWriter, а внутри сервиса на ошибке делаете new ConsoleAuditWriter() «на всякий случай». Это ломает контракт: теперь непонятно, какой аудит реально используется и почему при отключённом аудите в тихом режиме всё равно что-то печатается.
Ошибка №3: composition root начал принимать бизнес-решения.
Если в Main появляются условия уровня «если сумма заказа больше 1000 — то скидка такая, иначе такая», wiring превращается во второй бизнес-слой. Composition root должен выбирать реализации и собирать граф, но не подменять собой бизнес-логику.
Ошибка №4: интерфейс на каждый класс — просто потому что DI.
Если вы начинаете делать OrderService → OrderServiceImpl, ScenarioRunner → ScenarioRunnerImpl без реальной причины, проект быстро обрастает шумом. Интерфейс нужен там, где ожидается осмысленная вариативность — как у AuditWriter или NotificationSender, — а не ради галочки.
Ошибка №5: длинный конструктор игнорируем — значит всё нормально.
Конструктор из 8–10 зависимостей иногда говорит не «DI плохой», а «класс делает слишком много». На этом этапе ещё рано устраивать архитектурную революцию, но важно хотя бы заметить сигнал: возможно, сервису стоит выделить помощника и разделить ответственность.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ