JavaRush /Курсы /Spring Core /ContextFlow после ре...

ContextFlow после рефакторинга

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

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
LoyalCustomerDiscountPolicy
отправлять уведомления NotificationSender ConsoleNotificationSender
писать аудит AuditWriter ConsoleAuditWriter
NoOpAuditWriter
генерировать id OrderIdGenerator DeterministicOrderIdGenerator
UuidOrderIdGenerator

Теперь сами интерфейсы. Попробуйте читать их как «словарь возможностей», а не как «кусок кода».

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

Скидка сработала (1000900), уведомление отправилось, аудит записался — и всё это без единого 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.
Если вы начинаете делать OrderServiceOrderServiceImpl, ScenarioRunnerScenarioRunnerImpl без реальной причины, проект быстро обрастает шумом. Интерфейс нужен там, где ожидается осмысленная вариативность — как у AuditWriter или NotificationSender, — а не ради галочки.

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

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