1. Нужен честный baseline
Если после разговора про Spring Core сразу перейти к контейнеру, легко получить ощущение, что фреймворк решает какую-то абстрактную, чуть ли не академическую проблему. Чтобы этого не произошло, нужен честный baseline: маленькое приложение без Spring, где руками видно, кто создаёт объекты, кто что получает в конструктор и где вообще живёт код запуска 🛕.
Это важный методический момент 🗿. Spring лучше объяснять не через фразу «контейнер делает удобно», а через контраст. Пока не увидишь обычный Java-код, который приходится собирать вручную, слова вроде wiring, object graph и ApplicationContext звучат слишком абстрактно. А как только видишь plain Java версию, становится понятно, почему тема сборки приложения заслуживает отдельного инженерного разговора 🔧.
Поэтому у нас появляется сквозной проект курса — ContextFlow: Order Processing, Notifications & Audit. Не как «большой продукт», а как учебный полигон. Мы не строим интернет-магазин на сто сущностей, не уходим в web, не подключаем базу и не притворяемся, что в первый день нам нужен мир уровня production. Нам нужен сценарий, в котором естественно есть несколько связанных объектов и несколько побочных эффектов. Этого уже достаточно, чтобы показать, откуда растёт боль 💣.
2. Первый кусочек будущего проекта
Сегодняшний срез ContextFlow нарочно небольшой. Есть сценарий «создать заказ». Чтобы он сработал, нужно хотя бы сохранить заказ, отправить уведомление и записать аудит. Уже на этом масштабе появляются разные роли: кто-то отвечает за хранение, кто-то — за уведомление, кто-то — за фиксацию действия. А значит, появляются зависимости и вместе с ними — вопрос сборки.
Такой домен хорош по двум причинам. Во-первых, он понятный: заказ, уведомление и аудит не требуют отдельной лекции по предметной области. Во-вторых, он естественно растёт. Стоит захотеть отмену заказа, отчёт или другой канал уведомлений — и граф объектов начнёт усложняться без всякой искусственной драматургии. То есть сама тема курса будет проявляться через естественный рост приложения, а не через специально выдуманные сложности.
Сценарий можно представить очень просто:
sequenceDiagram
participant R as ScenarioRunner
participant S as OrderPlacementService
participant St as OrderStore
participant N as NotificationSender
participant A as AuditWriter
R->>S: placeOrder(command)
S->>St: save(order)
S->>N: send("order created")
S->>A: write("order created")
S-->>R: orderId
Здесь пока нет ничего про Spring 🪄, и это именно то, что нам нужно. Есть обычный use-case сервис, несколько зависимых объектов и точка запуска, которая должна всё это собрать. Если дальше держать в голове именно этот срез, все следующие разговоры про контейнер будут восприниматься уже не как абстракция, а как развитие конкретной ситуации.
3. Plain Java версия проекта
Давайте посмотрим на самый обычный старт приложения. Есть main, есть несколько зависимостей, есть сервис сценария и есть ScenarioRunner, который этот сценарий запускает. Пока всё собирается вручную, безо всякой магии и без Spring.
public class ContextFlowApp {
public static void main(String[] args) {
// Вручную создаём инфраструктурные зависимости (выбор конкретных реализаций).
OrderStore store = new InMemoryOrderStore(); // Хранилище заказов: пока in-memory
NotificationSender sender = new ConsoleNotificationSender(); // Канал уведомлений: пока консоль
AuditWriter audit = new ConsoleAuditWriter(); // Аудит: тоже пишем в консоль
// Вручную собираем use-case сервис: явно прокидываем зависимости через конструктор.
OrderPlacementService placement = new OrderPlacementService(store, sender, audit);
// Вручную создаём точку запуска сценария (bootstrap): кто-то должен «стартануть» use-case.
ScenarioRunner runner = new ScenarioRunner(placement);
runner.run(); // Запускаем сценарий (например, «создать заказ»)
}
}
У такого кода есть важное достоинство: он очень честный. Ничего не скрыто. Сразу видно, что OrderPlacementService не может работать без store, sender и audit. Для учебного текста это особенно полезно, потому что студент видит реальную форму зависимости, а не достраивает её по описанию.
Сам сервис тоже выглядит как обычный Java-класс. Не особенный, не завязанный на фреймворк и не «помеченный магией». Он просто использует то, что ему передали.
public class OrderPlacementService {
// Явные зависимости сервиса: без них use-case не может выполняться.
private final OrderStore store; // отвечает за сохранение заказа
private final NotificationSender sender; // отвечает за отправку уведомления
private final AuditWriter audit; // отвечает за запись аудита
public OrderPlacementService(
OrderStore store,
NotificationSender sender,
AuditWriter audit
) {
// Сохраняем зависимости — дальше сервис будет использовать их в бизнес-методах.
this.store = store;
this.sender = sender;
this.audit = audit;
}
public String placeOrder(CreateOrderCommand command) {
// Генерируем идентификатор заказа (упрощённо, просто для примера).
String orderId = "ORD-" + System.currentTimeMillis();
// Сохраняем доменный объект в хранилище.
store.save(new Order(orderId, command.customerName()));
// Побочный эффект №1: отправляем уведомление.
sender.send("Order created: " + orderId);
// Побочный эффект №2: пишем в аудит.
audit.write("Created order: " + orderId);
// Возвращаем идентификатор — результат сценария.
return orderId;
}
}
Пока это читается спокойно 🤯. Даже приятно. Класс выглядит нормально, зависимости явные, код запуска короткий. На таком масштабе проекта ручная сборка не кажется проблемой — и в этом честность первого уровня. Spring не нужно объявлять спасением человечества из-за одного конструктора. Но форма будущего роста уже видна: у одного сервиса три зависимости, и их кто-то должен выбрать, создать и связать 🔗.
4. Граф объектов на человеческом языке
Когда говорят «граф объектов», новичок легко слышит в этом что-то математически страшное 😅. На деле всё куда бытовее. Это просто карта того, какие объекты существуют в приложении и кто на кого опирается. В нашем примере граф пока небольшой, но он уже есть: ContextFlowApp создаёт OrderPlacementService, а тот зависит от OrderStore, NotificationSender и AuditWriter.
flowchart TD
App[ContextFlowApp.main] --> Runner[ScenarioRunner]
App --> Placement[OrderPlacementService]
Placement --> Store[OrderStore]
Placement --> Notify[NotificationSender]
Placement --> Audit[AuditWriter]
Пока граф маленький, им легко управлять глазами 👀. Но именно здесь рождается важный вопрос: кто отвечает за правила этой сборки? Кто решил, что NotificationSender будет консольным? Кто решил, что AuditWriter тоже консольный? Кто держит в голове, какой объект общий, а какой можно спокойно создавать отдельно? Эти решения уже существуют, просто пока их немного.
Ещё одно полезное слово здесь — bootstrap. Это код старта приложения, то место, где оно собирает себя перед выполнением первого сценария. В простом Java-мире bootstrap очень часто живёт прямо в main. И это нормально, пока приложение маленькое. Но как только в bootstrap начинают стекаться детали из разных уголков системы, он перестаёт быть «несколькими строчками запуска» и становится отдельной зоной заботы.
5. Код старта растёт быстрее, чем кажется
Самая коварная часть ручного связывания объектов (wiring) не в количестве new. Коварство в другом: код запуска начинает знать слишком много о внутреннем устройстве приложения. Допустим, вы добавили второй сценарий — отмену заказа. Теперь нужен ещё один сервис, и ему тоже требуются зависимости.
// Новый use-case сервис: отмена заказа (использует уже созданные зависимости).
OrderCancellationService cancellation = new OrderCancellationService(store, audit); // важно: тот же store/audit
// Ещё один сценарий: отчёты (ему нужен только store).
ReportingService reporting = new ReportingService(store); // снова важно: тот же store, чтобы видеть те же данные
// Точка запуска теперь знает про несколько сценариев и должна всё это связать.
ScenarioRunner runner = new ScenarioRunner(placement, cancellation, reporting);
В этот момент main уже знает, какие зависимости нужны placement, какие нужны cancellation, какие объекты должны быть общими, а какие — нет. И вот здесь начинается интересная инженерная развилка 🤔. На уровне одной новой строчки кажется, что ничего страшного не произошло. Но на уровне архитектуры код запуска постепенно превращается в место, где сосредоточено слишком много знания о приложении.
Есть ещё более тонкий момент 👀. Некоторые объекты недостаточно просто создать — их нужно создать правильно относительно друг друга. Например, если OrderPlacementService сохраняет заказ в одном InMemoryOrderStore, а OrderCancellationService ищет его уже в другом, приложение будет работать, но неправильно. То есть ручной wiring рождает не только длинный bootstrap, но и новый класс ошибок: ошибок сборки графа, которые не всегда проявляются явным исключением 🧨.
Из-за этого рост ручного wiring обманчив. Снаружи кажется, что вы просто добавляете ещё один сервис. Но внутри появляются новые правила согласования: какие объекты должны быть общими, где находится точка выбора реализации, какой объект должен жить весь runtime приложения, а какой можно создавать на каждый вызов. И именно эта невидимая часть потом растёт быстрее, чем сами бизнес-методы.
6. Ручное связывание не преступление, но у него есть потолок
На этом месте важно не сделать неверный вывод: «значит, ручная сборка всегда плохая» 🤬. Нет. Для маленькой утилиты, короткого скрипта или очень простого приложения ручной wiring может быть совершенно адекватным выбором. И честный курс по Spring должен это проговорить. Фреймворк не нужен там, где проблема ещё не возникла.
Но у ручного wiring есть вполне наблюдаемый потолок. Он упирается не в количество строк, а в количество решений о сборке, которые нужно удерживать в голове. Пока зависимость одна и сценарий один, всё терпимо. Когда сценариев несколько, реализаций становится больше одной, а поведение приложения начинает зависеть от конфигурации или режима запуска, сборка становится отдельной задачей.
Это можно увидеть в очень компактном контрасте:
| Пока всё ещё спокойно | Уже начинается инженерное напряжение |
|---|---|
| один use-case сервис | несколько сценариев с общими объектами |
| одна реализация зависимости | несколько реализаций одной роли |
| main знает пару new | bootstrap знает внутреннюю структуру половины приложения |
| баги обычно видны сразу | часть проблем — это ошибки самого wiring |
Вот где появляется взрослая мысль 😱. Оказывается, у приложения есть не только бизнес-логика, но и логика собственной сборки. И пока эту логику разработчик держит вручную, она живёт либо в main, либо в самодельных фабриках, либо размазывается по коду. Spring появляется не там, где лениво писать new, а там, где сборка приложения уже стала отдельной инженерной задачей 🔧.
7. Нам нужен ContextFlow
Сквозной проект нужен не ради красивого названия. Он нужен, чтобы все последующие темы опирались на один и тот же предметный контекст. Если сегодня объяснять manual wiring на заказах, завтра lifecycle — на библиотеке книг, а послезавтра events — на банковском примере, студент тратит силы не на механику Spring, а на постоянную смену декораций.
ContextFlow хорош тем, что позволяет расти естественно. Сегодня нам хватило OrderPlacementService, OrderStore, NotificationSender, AuditWriter и ScenarioRunner. Чуть дальше проект спокойно выдержит несколько реализаций уведомлений, конфигурацию режимов, ресурсы с шаблонами, application events, post-processors и даже proxy-based поведение. При этом домен останется достаточно простым, чтобы не заслонить собой тему курса.
Именно поэтому сегодня мы не выгружаем весь каталог сущностей, сервисов и пакетов. Для первого дня это был бы не плюс, а перегруз. Пока достаточно увидеть одну живую бизнес-цепочку и заметить, как быстро вокруг неё возникает вопрос сборки. Следующий естественный шаг уже не про сам домен, а про качество этого plain Java кода: может ли он работать и при этом быть архитектурно неудобным? Ответ, к сожалению, да 😐. И именно здесь начинаются сильное связывание, скрытые зависимости и цена изменений.
8. Типичные ошибки 👺
Ошибка №1: объявить manual wiring злом сразу и навсегда.
Такой вывод слишком грубый. На крошечном коде ручная сборка может быть нормальной. Важно не запретить new, а понять, где число решений о сборке становится самостоятельной проблемой.
Ошибка №2: измерять рост сложности только количеством строк.
Иногда main всё ещё короткий, но уже знает слишком много о структуре приложения. Проблема ручной сборки чаще проявляется как рост связей и правил согласования, а не просто как длинный метод.
Ошибка №3: слишком рано раздувать домен.
Если на первом дне втащить платежи, доставку, пользователей, статусы, скидки и интеграции, тема курса растворится в предметной области. Нам нужен не «реальный бизнес целиком», а правильный срез, на котором хорошо видно проблему сборки.
Ошибка №4: думать, что боль начинается только тогда, когда приложение упало.
Одна из самых неприятных особенностей ручной сборки в том, что часть ошибок не валит программу сразу. Она просто делает её трудной для изменения, чтения и локальной проверки. И это часто опаснее явного падения 👍.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ