1. Сервісний шар: вирівнюємо стиль DI
Якщо проєкт — це місто, то сервісний шар у ньому — як транспортна система: наче й не найкрасивіший квартал, але саме від нього залежить, чи дійде «замовлення створено» до «аудит записано» і «сповіщення надіслано». Сервіси найчастіше стають ядром об’єктного графа, тож будь-які дивні рішення з DI там розмножуються на весь застосунок.
У ContextFlow сервіси — це класи, що оркеструють сценарії: створення замовлення, скасування замовлення, формування звіту. До них стікаються залежності від сховища, логіки ціноутворення, аудиту та сповіщень. І якщо сервіси впроваджуються по-різному — частина через поля, частина через конструктори, частина «а я тут тихенько зробив new усередині методу» — у проєкту з’являється характер. Зазвичай неприємний. Тому наприкінці дня ми приводимо весь сервісний шар до одного стилю, щоб проєкт читався однаково в будь-якій точці.
Карта сервісів ContextFlow
Перед рефакторингом корисно зробити просту річ: не переписувати код «на натхненні», а скласти карту — які сервіси у нас є і які залежності в них справді обов’язкові. Це звучить нудно, але на практиці економить години, бо ви перестаєте гадати, «чому цей сервіс узагалі існує», і починаєте бачити систему як набір ролей.
У ContextFlow ролі спеціально зроблені «тонкими», щоб ми вчили Spring, а не будували новий Amazon. Попри це, в кожної ролі є зрозумілі обов’язкові «колаборатори». Нижче — робочий зріз сервісного шару на цей момент.
| Клас (сервіс) | Що робить за змістом | Обов’язкові залежності (через конструктор) |
|---|---|---|
| OrderPlacementService | створює замовлення | OrderIdGenerator, , , , |
| OrderCancellationService | скасовує замовлення | OrderStore, , |
| OrderPricingService | обчислює підсумок | DiscountPolicy |
| AuditService | записує бізнес-аудит | AuditWriter |
| NotificationDispatchService | надсилає сповіщення | NotificationSender |
| ReportingService | будує звіт | OrderStore, |
| ScenarioRunner | запускає сценарії | OrderPlacementService, , |
Цю карту тримаємо як робочий зріз дня: вона не перелічує кожен допоміжний клас проєкту, а фіксує обов’язкові залежності сервісів. Саме за нею далі зручно перевіряти, чи не розповзлася роль сервісу і чи не з’явився десь прихований new.
2. Розминка: AuditService і NotificationDispatchService
Перехід на впровадження через конструктор краще починати з маленьких і очевидних сервісів, щоб «рука згадала», як це робиться, і щоб ви швидко побачили виграш у читабельності. AuditService і NotificationDispatchService підходять ідеально: у кожного по одній обов’язковій залежності, роль зрозуміла, а рефакторинг займає хвилини, зате дає багато користі.
Спочатку зафіксуємо порти — інтерфейси, від яких залежать сервіси. Вони вже були в попередніх днях, але невеликий фрагмент допомагає тримати картину в голові:
public interface AuditWriter {
// Порт для інфраструктури аудиту: сервісний шар не повинен знати,
// КУДИ і ЯК саме записується аудит (у консоль, файл, БД, журнал тощо).
void write(String message);
}
Тепер AuditService у стилі впровадження через конструктор. Зверніть увагу: жодного @Autowired не потрібно, бо конструктор один, а Spring чудово вміє здогадатися, що саме його й треба використовувати.
import com.example.contextflow.domain.ports.AuditWriter;
import org.springframework.stereotype.Service;
@Service
public class AuditService {
// Залежність обов’язкова: без неї сервіс не має сенсу.
private final AuditWriter auditWriter;
public AuditService(AuditWriter auditWriter) {
// Конструктор фіксує залежність один раз під час створення біна.
this.auditWriter = auditWriter;
}
public void audit(String message) {
auditWriter.write(message);
}
}
І точно так само NotificationDispatchService. Уся «магія» не в анотації, а в тому, що залежність виноситься назовні і стає частиною контракту класу.
import com.example.contextflow.domain.ports.NotificationSender;
import org.springframework.stereotype.Service;
@Service
public class NotificationDispatchService {
// Відправник сповіщень — інфраструктурна залежність.
private final NotificationSender sender;
public NotificationDispatchService(NotificationSender sender) {
// Важливо: не створюємо sender через new усередині сервісу.
this.sender = sender;
}
public void dispatch(String message) {
sender.send(message);
}
}
Далі ці класи працюють на рівні сценарію: ми викликаємо audit і dispatch, а деталі запису та надсилання залишаються в AuditWriter і NotificationSender.
Якщо ви зараз думаєте: «Ну це ж просто два поля і конструктор, навіщо так урочисто?» — це нормальна реакція. Впровадження через конструктор часто виглядає занадто простим, щоб бути справді важливим. Але фокус у масштабі: коли таких класів стає 30, єдиний стиль перетворює читання проєкту з археології на звичайне читання.
3. OrderPlacementService і впровадження через конструктор
OrderPlacementService — зазвичай сервіс із найбільшою кількістю залежностей: він оркеструє головний сценарій, тож йому справді потрібно більше колабораторів. Саме на ньому найсильніше відчувається різниця між «залежності заховані» і «залежності видимі». А ще саме він найчастіше перетворюється на сервіс-бог, якщо не стежити за межами відповідальності.
Почнемо з конструктора і final-полів. Так, конструктор вийшов не на дві стрічки — і це нормально: сервіс справді залежить від кількох речей. Ненормально стане потім, якщо він почне залежати «від усього». Якщо ціноутворення, аудит і сповіщення входять у сценарій розміщення замовлення, їм і місце в конструкторі поруч із генерацією id та збереженням.
import org.springframework.stereotype.Service;
@Service
public class OrderPlacementService {
// Генератор ідентифікаторів замовлення — обов’язковий колаборатор.
private final OrderIdGenerator orderIdGenerator;
// Сховище замовлень — обов’язковий колаборатор для збереження результату сценарію.
private final OrderStore orderStore;
// Сервіс ціноутворення — частина бізнес-правил, теж обов’язковий.
private final OrderPricingService pricingService;
// Аудит і сповіщення — такі самі видимі частини сценарію, а не приховані побічні ефекти.
private final AuditService auditService;
private final NotificationDispatchService notificationDispatchService;
public OrderPlacementService(OrderIdGenerator orderIdGenerator,
OrderStore orderStore,
OrderPricingService pricingService,
AuditService auditService,
NotificationDispatchService notificationDispatchService) {
// Конструктор — єдине місце, де «збирається» об’єкт.
this.orderIdGenerator = orderIdGenerator;
this.orderStore = orderStore;
this.pricingService = pricingService;
this.auditService = auditService;
this.notificationDispatchService = notificationDispatchService;
}
}
Так виглядає OrderPlacementService на поточному зрізі проєкту: якщо сценарій справді включає обчислення ціни, збереження, аудит і сповіщення, усі ці ролі мають бути на видноті, а не сховані по методах.
Щоб відчути практичний сенс, подивімося на маленький шматок сценарію. Метод не зобов’язаний бути повністю реалістичним із погляду бізнесу; для нас важливо, що він викликає залежності, а залежності не створюються всередині.
public String placeOrder(CreateOrderCommand cmd) {
// 1) Генеруємо ідентифікатор замовлення.
String id = orderIdGenerator.nextId();
// 2) Обчислюємо підсумкову суму через сервіс ціноутворення.
var total = pricingService.calculateTotal(cmd.items());
// 3) Зберігаємо замовлення у сховище.
orderStore.save(id, cmd.customerId(), cmd.items(), total);
// 4) Фіксуємо побічні ефекти через уже впроваджені сервіси.
auditService.audit("ORDER_PLACED: " + id);
notificationDispatchService.dispatch("Order created: " + id);
// 5) Повертаємо id як результат сценарію.
return id;
}
Навіть такий короткий фрагмент методу показує роль сервісу: він оркеструє кроки, а не починає сам обчислювати знижки, писати в консоль чи обирати канал сповіщень через if-else.
4. OrderCancellationService без прихованих залежностей
Скасування замовлення — хороший приклад того, чому корисно мати окремий сервіс для окремого сценарію. Новачки часто намагаються запхати все в один клас «OrderService», бо «ну це ж усе про замовлення». А потім раптом у цьому класі виявляються двадцять методів і десять залежностей, і він починає нагадувати швейцарський ніж, який намагається бути ще й ложкою.
OrderCancellationService зазвичай має менше залежностей, ніж placement, і читається ще приємніше. Тут важливо, що залежності, потрібні для оркестрації, видно одразу: нам потрібні сховище, аудит і сповіщення.
import org.springframework.stereotype.Service;
@Service
public class OrderCancellationService {
// Потрібен доступ до стану замовлень, щоб скасувати конкретне замовлення.
private final OrderStore orderStore;
// Аудит і сповіщення — обов’язкові частини сценарію скасування.
private final AuditService auditService;
private final NotificationDispatchService notificationDispatchService;
public OrderCancellationService(OrderStore orderStore,
AuditService auditService,
NotificationDispatchService notificationDispatchService) {
// Впровадження через конструктор: усе обов’язкове «на вітрині».
this.orderStore = orderStore;
this.auditService = auditService;
this.notificationDispatchService = notificationDispatchService;
}
}
І невеликий фрагмент сценарію, щоб побачити, що сервіс справді працює. Знову ж таки: жодного new, жодних пошуків бінів — лише робота через уже впроваджені залежності.
public void cancel(String orderId) {
// 1) Змінюємо стан замовлення у сховищі.
orderStore.cancel(orderId);
// 2) Пишемо бізнес-аудит і надсилаємо сповіщення.
auditService.audit("ORDER_CANCELLED: " + orderId);
notificationDispatchService.dispatch("Order cancelled: " + orderId);
}
Тут добре видно, що скасування замовлення — окремий сценарій зі своїм набором обов’язкових залежностей, а не випадковий метод у гігантському OrderService.
5. ReportingService і ScenarioRunner
ReportingService і звіти
Із звітами є типова пастка: здається, що раз ми «формуємо файл», то можна прямо в сервісі відкрити FileWriter, зібрати рядок, записати, закрити. У навчальному проєкті це навіть працює. Але щойно ви захочете змінити формат звіту або місце, куди його виводимо, раптом виявиться, що сервіс розрісся інфраструктурними деталями. Саме тому ми робимо ReportFormatter окремою залежністю.
Ми вже звикли, що частину інфраструктури зручно реєструвати через @Bean. Але для сервісу не важливо, як саме зареєстровано залежність. Йому важливо, що залежність приходить через конструктор і лишається стабільною.
import org.springframework.stereotype.Service;
@Service
public class ReportingService {
// Джерело даних для звіту: сервіс знає, ЩО потрібно взяти, але не знає, ЯК це зберігається.
private final OrderStore orderStore;
// Форматувач — окрема роль: перетворює дані на рядок/формат звіту.
private final ReportFormatter reportFormatter;
public ReportingService(OrderStore orderStore, ReportFormatter reportFormatter) {
// Усі обов’язкові залежності передаються явно.
this.orderStore = orderStore;
this.reportFormatter = reportFormatter;
}
}
А ось мініфрагмент, де видно цю взаємодію: сервіс бере дані з OrderStore, передає їх форматувачу, отримує рядок (або інший результат) і далі вже вирішує, що з цим робити.
public String buildDailyReport() {
// 1) Отримуємо дані (насправді тут може бути фільтрація за датою тощо).
var orders = orderStore.findAll();
// 2) Доручаємо форматування, щоб не змішувати бізнес-оркестрацію та подання.
return reportFormatter.format(orders);
}
Зверніть увагу, як ReportingService залишився сервісом, а не файловим менеджером. Навіть якщо пізніше в нас з’явиться складніша інфраструктура виводу, сам принцип збережеться: бізнес-шар просить «сформуй звіт», а інфраструктура вирішує «як саме».
ScenarioRunner і wiring без new
ScenarioRunner — це наш диригент, який запускає демонстраційні сценарії застосунку. Це не чистий бізнес-сервіс, але він дуже корисний методично: він показує, що можна мати один стартовий компонент, який отримує сервіси й викликає їхні методи. І саме на ScenarioRunner легко побачити: збирання залежностей має жити або в контейнері, або в конфігурації, але не посередині бізнес-методів.
Спочатку сам ScenarioRunner як звичайний клас із конструктором. Його можна реєструвати через @Component, але в нашому курсі зручно показати варіант через @Bean, щоб ви бачили: стиль впровадження не залежить від способу реєстрації.
public class ScenarioRunner {
// Сценарії запускаються через сервіси, а не через ручне збирання залежностей.
private final OrderPlacementService placementService;
private final OrderCancellationService cancellationService;
private final ReportingService reportingService;
public ScenarioRunner(OrderPlacementService placementService,
OrderCancellationService cancellationService,
ReportingService reportingService) {
// Впроваджуємо обов’язкові сервіси через конструктор.
this.placementService = placementService;
this.cancellationService = cancellationService;
this.reportingService = reportingService;
}
public void run() {
// Тут зазвичай будуть виклики placementService, cancellationService і reportingService.
System.out.println("ContextFlow запущено");
}
}
Тепер конфігурація, яка створює ScenarioRunner. Тут важливий момент: @Bean-метод може приймати параметри, і Spring сам підставить потрібні біни. У підсумку конфігурація залишається тонкою: жодних ручних ctx.getBean усередині, жодних прихованих фабрик.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ScenarioConfig {
@Bean
public ScenarioRunner scenarioRunner(OrderPlacementService placementService,
OrderCancellationService cancellationService,
ReportingService reportingService) {
// Spring сам передасть потрібні біни як параметри методу.
return new ScenarioRunner(placementService, cancellationService, reportingService);
}
}
Так збирання залежностей залишається на поверхні: залежності ScenarioRunner видно прямо в сигнатурі @Bean-методу, а не добуваються потім через контейнер у ході сценарію.
І, нарешті, запуск застосунку. Це не головна тема дня, але корисно бачити, що весь наш акуратний constructor-driven сервісний шар прекрасно запускається у звичайному AnnotationConfigApplicationContext.
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ContextFlowApplication {
public static void main(String[] args) {
// Контекст закриваємо через try-with-resources, щоб коректно звільнити ресурси.
try (var ctx = new AnnotationConfigApplicationContext(ContextFlowAppConfig.class)) {
// Дістаємо сценарійний раннер із контексту і запускаємо демо-сценарій.
ctx.getBean(ScenarioRunner.class).run();
}
}
}
Якщо ви зараз відчуваєте, що код став нуднішим — менше магії, більше явних конструкторів — вітаю: це нормальна ознака того, що проєкт дорослішає. Справжня стабільність зазвичай виглядає трохи нудно. Як хороші гальма: ніхто не захоплюється гальмами, доки вони не знадобляться.
6. Читання графа залежностей за конструкторами
Коли у вас на всьому сервісному шарі єдине впровадження через конструктор, з’являється приємний побічний ефект: ви можете читати проєкт як карту метро. Конструктор — це станція пересадки: по ньому видно, до чого сервіс під’єднаний. І навіть якщо ви ще не сильні в Spring, ви вже можете робити дорослу діагностику: «цей сервіс не повинен залежати від цього компонента».
Ось як може виглядати спрощений граф залежностей ContextFlow на нашому поточному етапі:
flowchart TD
ScenarioRunner --> OrderPlacementService
ScenarioRunner --> OrderCancellationService
ScenarioRunner --> ReportingService
OrderPlacementService --> OrderIdGenerator
OrderPlacementService --> OrderStore
OrderPlacementService --> OrderPricingService
OrderPlacementService --> AuditService
OrderPlacementService --> NotificationDispatchService
OrderCancellationService --> OrderStore
OrderCancellationService --> AuditService
OrderCancellationService --> NotificationDispatchService
ReportingService --> OrderStore
ReportingService --> ReportFormatter
OrderPricingService --> DiscountPolicy
AuditService --> AuditWriter
NotificationDispatchService --> NotificationSender
Важливо, що цю схему майже автоматично можна відновити з коду. Вам не потрібен окремий документ. Якщо хтось у команді додав залежність, наприклад, OrderPlacementService почав залежати від ReportFormatter, ви це побачите прямо в конструкторі й зможете поставити запитання: «А навіщо?». І це одна з головних причин, чому ми сьогодні так наполегливо вирівнювали стиль: конструктор перетворюється на документацію, яка не застаріває, бо компілятор не дозволить їй застаріти.
7. Типові помилки під час впровадження через конструктор
Помилка №1: перевели сервіс на конструктор, але залишили new конкретної реалізації всередині сценарію.
Наприклад, OrderPlacementService чесно отримує NotificationDispatchService, а потім десь у методі все одно робить new ConsoleNotificationSender(). У такий момент сервіс знову починає сам вибирати інфраструктуру, і єдиний граф залежностей розвалюється. Якщо залежність потрібна сервісу, вона має приходити ззовні, а не народжуватися посеред use case.
Помилка №2: вирівняли сервісний шар лише наполовину.
Один сервіс через впровадження через конструктор, сусідній через field injection, третій через setter «бо лінь переписувати». Ззовні здається, що все працює, але читати й діагностувати такий проєкт значно важче: у частини класів контракт угорі, у частини — розмазаний по полях. Сенс єдиного стилю якраз у тому, щоб граф залежностей читався однаково в будь-якому сервісі.
Помилка №3: ігнорувати сигнал великого конструктора й далі складати в сервіс нові ролі.
Якщо OrderPlacementService раптом починає просити ще ReportFormatter, файловий writer і щось для експорту, проблема не в конструкторі як такому. Проблема в тому, що use-case сервіс роздувся і просить рефакторингу відповідальності, а не анотаційного макіяжу. Впровадження через конструктор тут корисне саме тим, що показує перевантаження чесно й без маскування.
Помилка №4: перетворювати ScenarioRunner або конфігурацію на service locator.
Щойно в run() або в @Bean-методах з’являється ctx.getBean заради отримання «ще одного потрібного сервісу», явне збирання залежностей зникає. Для точки входу контейнер доречний як місце, звідки ви берете готовий ScenarioRunner, але не як костиль для прихованого пошуку залежностей у ході сценарію.
Помилка №5: виконувати роботу в конструкторі замість того, щоб просто зібрати об’єкт.
Інколи хочеться в конструкторі одразу скасувати замовлення, побудувати звіт чи надіслати сповіщення «бо залежності вже є». Так сервіс починає робити побічні ефекти на старті контексту, і поведінка застосунку стає менш передбачуваною. Конструктор має збирати об’єкт, а сценарій має жити у звичайних методах.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ