1. Java-картина: світ крізь new
У маленьких консольних застосунках і навчальних проєктах найприродніший шлях — створювати потрібні обʼєкти просто там, де вони потрібні: через new. Це швидко й зрозуміло, поки класів мало й голова не перевантажена. Проблема починається пізніше: коли класів стає більше, а звʼязки між ними — щільнішими. Тоді невинний new усередині класу перетворюється на «приховану проводку в стіні»: поки все працює — ви задоволені, а коли настає час щось змінити, доводиться ламати майже весь будинок.
Подивімося на найпростіший приклад у стилі «так робить майже кожен початківець». Є репозиторій (джерело даних) і сервіс (логіка поверх даних):
import java.util.List;
class InMemoryCourseCatalogRepository {
List<String> findAllSlugs() {
// Тут репозиторій «вдає», що звертається по дані,
// хоча насправді повертає список, зашитий у код.
return List.of("spring-boot", "spring-core");
}
}
class CourseCatalogService {
// Ключова проблема: сервіс сам створює залежність.
// Ззовні не видно, що йому взагалі потрібен репозиторій і який саме.
private final InMemoryCourseCatalogRepository repository = new InMemoryCourseCatalogRepository();
}
Поки що репозиторій тут конкретний і in-memory. Це спеціально спрощена стартова точка: спочатку важливо побачити саму проблему, яку створює new усередині класу.
На перший погляд усе нормально. Але зверніть увагу: залежність repository захована всередині CourseCatalogService. Ззовні не видно, що сервісу взагалі потрібен репозиторій, який саме, чи можна його замінити і скільки таких репозиторіїв у підсумку буде створено.
Це схоже на ситуацію, коли ви приходите в кафе, замовляєте «каву», а вам приносять каву + випадковий сироп + випадковий розмір стакана, бо бариста вирішив «так краще». Можливо, навіть смачно. Але ж ви цього не замовляли. І головне — ви не керуєте вибором.
2. Приховані залежності — практичний біль
Поки проєкт маленький, прихована залежність здається дрібницею. Але в міру зростання коду вона починає створювати цілком реальні інженерні проблеми. Найнеприємніша з них — ви втрачаєте контроль над тим, як застосунок влаштований як система: з яких частин він складається й як вони зʼєднані.
Уявіть, що ви пишете catalog-service — наш навчальний сервіс каталогу курсів. Сьогодні дані лежать у памʼяті, завтра — в конфігурації, післязавтра — у файлі, а ще пізніше знадобиться заглушка для тесту. Якщо CourseCatalogService сам робить new InMemoryCourseCatalogRepository(), у вас просто немає зручної точки, де можна сказати: «А зараз сервіс має працювати з іншою реалізацією репозиторію».
Є й тонкіша проблема: клас отримує дві відповідальності замість однієї. Він не лише робить свою роботу, наприклад надає операції каталогу, а й займається збиранням інфраструктури навколо себе: вирішує, які саме обʼєкти створювати, в якому порядку і з якими параметрами. У підсумку в одному місці змішуються логіка та збирання світу, і читати такий код стає важче.
Щоб різниця була зовсім наочною, зафіксуємо її в компактній таблиці:
| Що відбувається | Коли залежність створюється через new всередині класу | Коли залежність передається ззовні |
|---|---|---|
| Чи видно з коду, що класу потрібна залежність | Не видно: залежність захована в полях | Видно одразу: у конструкторі |
| Чи можна легко замінити реалізацію | Майже ні: доведеться правити код класу | Так: змінюємо збирання, клас не чіпаємо |
| Скільки екземплярів буде створено | Часто «скільки завгодно» і випадково | Зазвичай «скільки потрібно» і свідомо |
| Чи зручно тестувати | Боляче: складно підміняти залежності | Простіше: можна підставити заглушку |
І тут важливо правильно зрозуміти: ніхто не забороняє new як інструмент. new прекрасний, коли ви створюєте прості обʼєкти-дані або щось локальне всередині методу. Проблема виникає тоді, коли через new створюються важливі компоненти системи — сервіси, репозиторії, клієнти, обробники, — які мають бути замінними й керованими.
3. IoC: не ви викликаєте, а викликають вас
IoC часто звучить як термін з архітектурних трактатів — тих самих, де дуже люблять діаграми й не надто люблять людей. Але сама ідея проста: у звичайному застосунку ви почуваєтеся головним. Ви пишете main, викликаєте методи й керуєте порядком дій. У фреймворку «головний» — сам фреймворк. Ваш код стає набором компонентів, які вбудовуються у загальний механізм, а вже цей механізм вирішує, коли й як вас викликати.
Найбільш побутова формула:
— бібліотека — це коли ви викликаєте код бібліотеки;
— фреймворк — це коли код фреймворку викликає ваш код.
Щоб не привʼязуватися до Spring, візьмімо приклад просто з Java. Сортування списку — це готовий алгоритм, майже «фреймворк у мініатюрі», а Comparator — ваш «фрагмент логіки», який буде викликаний усередині цього алгоритму:
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
// Ми готуємо дані...
List<String> slugs = new ArrayList<>(List.of("spring-boot", "java"));
// ...і передаємо "шматок поведінки" назовні: як порівнювати елементи.
// Важливо: ми не керуємо тим, скільки разів і коли буде викликано comparator.
slugs.sort(Comparator.comparing(String::length));
System.out.println(slugs); // [java, spring-boot]
Ключовий момент тут не в сортуванні. Він у тому, що ви не керуєте тим, як саме відбувається сортування і коли саме викликається порівняння. Ви просто віддаєте «шматок поведінки» (компаратор), а алгоритм викликає його стільки разів, скільки потрібно. Це й є маленька модель IoC: «не я вирішую, коли мене покличуть; мене покличуть усередині спільного механізму».
Spring розгортає ту саму ідею на рівні всього застосунку. Ви не повинні вручну вирішувати, коли створити цей сервіс, у якому порядку створити ці компоненти чи як звʼязати їхні залежності. Натомість ви описуєте компоненти, а система збирання — контейнер — бере керування на себе.
4. DI: явні залежності без зайвої лапші
DI — це практичний спосіб реалізувати IoC на рівні класів. Якщо IoC — це ідея «керування не в мене», то DI — це техніка «я не створюю залежність усередині класу, а отримую її ззовні». Якщо по-людськи: клас не повинен сам добувати собі все необхідне, як герой RPG, який голяка виходить у ліс і крафтить меч із палиці. Клас має чесно сказати, що йому потрібно, і отримати це ззовні.
Найпростіший і найздоровіший варіант DI — через конструктор. Тоді залежності стають частиною «контракту» класу: якщо вам потрібен repository, так і напишіть це в конструкторі.
Ось той самий сервіс, але вже з DI:
class CourseCatalogService {
// Тепер залежність явно зберігається в полі...
private final InMemoryCourseCatalogRepository repository;
CourseCatalogService(InMemoryCourseCatalogRepository repository) {
// ...і явно передається ззовні.
// Сервіс не вирішує, який репозиторій використовувати — він просто працює з тим, що йому дали.
this.repository = repository;
}
}
Ця маленька зміна дає великий ефект. Тепер будь-яка людина — і будь-який інструмент — бачить: щоб створити CourseCatalogService, потрібен InMemoryCourseCatalogRepository. Це поки що все ще конкретна залежність, але вона вже перестала бути прихованою. Сервіс більше не збирає собі світ сам.
Давайте зробимо приклад трохи живішим, щоб побачити користь. Додамо метод countCourses():
class CourseCatalogService {
private final InMemoryCourseCatalogRepository repository;
CourseCatalogService(InMemoryCourseCatalogRepository repository) {
this.repository = repository;
}
int countCourses() {
// Сервіс займається логікою поверх даних,
// а не "добуванням" цих даних (створенням репозиторію).
return repository.findAllSlugs().size();
}
}
Тепер сервіс справді працює із залежністю, і одразу зрозуміліше, навіщо вона йому потрібна.
Поки сервіс усе ще привʼязаний до конкретного in-memory класу. Щоб замінити реалізацію без зміни самого сервісу, потрібен ще один крок — відокремити контракт від реалізації.
Важливо: DI не вимагає Spring. DI — це стиль проєктування, який можна і потрібно застосовувати навіть у звичайній Java. Spring просто автоматизує збирання залежностей і знімає рутину, але сама ідея залишається тією самою.
5. Точка збирання: граф обʼєктів в одному місці
Якщо класи перестали створювати залежності самі, виникає логічне запитання: «Гаразд, а хто тоді їх створює?» Хтось повинен. Просто тепер це робиться в одному місці, де видно всю картину. Це місце часто називають composition root — точкою, де застосунок «збирається» з деталей.
У простому застосунку composition root — це ваш main():
public class CatalogApp {
public static void main(String[] args) {
// Точка збирання: тут створюємо компоненти й повʼязуємо їхні залежності.
InMemoryCourseCatalogRepository repository = new InMemoryCourseCatalogRepository();
// Важливо: сервіс не робить new InMemoryCourseCatalogRepository() всередині себе.
CourseCatalogService service = new CourseCatalogService(repository);
System.out.println(service.countCourses()); // 2
}
}
Тут важливо те, що ланцюжок залежностей став прозорим. Видно, де і в якому порядку збираються обʼєкти, а звʼязування більше не розмазане по класах із new.
Але залежність поки що все ще конкретна. Щоб по-справжньому показати замінність, уведімо маленьку абстракцію. Так, слово «інтерфейс» у багатьох викликає відчуття, що зараз почнеться ООП-ритуал, але ми зробимо все чесно й по мінімуму.
import java.util.List;
interface CourseCatalogRepository {
// Контракт: "умій повертати список курсів" (або їхніх ідентифікаторів).
List<String> findAllSlugs();
}
class InMemoryCourseCatalogRepository implements CourseCatalogRepository {
public List<String> findAllSlugs() {
// Реалізація в памʼяті — зручно для навчального проєкту та тестів.
return List.of("spring-boot", "spring-core");
}
}
Тепер наш сервіс залежить не від конкретного класу, а від контракту:
class CourseCatalogService {
// Тепер сервіс залежить від інтерфейсу (контракту), а не від конкретної реалізації.
private final CourseCatalogRepository repository;
CourseCatalogService(CourseCatalogRepository repository) {
// Це і є місце, де відбувається інʼєкція залежності (injection).
this.repository = repository;
}
int countCourses() {
return repository.findAllSlugs().size();
}
}
А в main() ми вирішуємо, яку реалізацію дати сервісу:
public class CatalogApp {
public static void main(String[] args) {
// Рішення "яка реалізація потрібна" ухвалюється на рівні збирання застосунку.
CourseCatalogRepository repo = new InMemoryCourseCatalogRepository();
// Сервісу байдуже, що це саме InMemory — він бачить лише контракт.
CourseCatalogService service = new CourseCatalogService(repo);
System.out.println(service.countCourses()); // 2
}
}
Ось тут уже зʼявляється смак майбутнього Spring-підходу: застосунок — це граф обʼєктів, і цей граф можна збирати по-різному. Не тому, що «так модно», а тому, що так простіше керувати складністю.
Якщо хочеться закріпити картину візуально, ось як виглядає наш мініграф залежностей; стрілка тут означає «залежить від»:
flowchart LR
Service[Сервіс каталогу курсів] --> Repo[Репозиторій каталогу курсів]
З ростом проєкту граф стане більшим, але ідея залишиться тією самою: залежності спрямовані «вниз», а збирання — «зверху».
6. Коли Spring Boot здається магією
Spring Boot сильно спрощує старт проєкту: залежності, автоконфігурація, запуск застосунку — усе відбувається швидко. Саме тому початківець легко потрапляє в пастку: здається, що достатньо кількох анотацій, а далі «воно саме». Але це «саме» влаштоване не як фокус, а як реалізація IoC/DI на рівні платформи.
Якщо не розуміти IoC і DI, легко почати робити типові речі зі звичайної Java, які у Spring-світі ламають архітектуру зсередини. Наприклад, можна написати клас, який усередині себе збирає все через new, бо «так звично». Формально такий код навіть може працювати, але ви поступово вимикаєте головну цінність Spring: кероване збирання застосунку.
Дуже практичне правило, яке варто запамʼятати ще до Spring-анотацій: важливі компоненти застосунку мають бути простими Java-класами з явними залежностями, а їхнє створення має відбуватися ззовні. Вручну — це main(). У Spring — це контейнер. Але стиль проєктування однаковий.
І ось тут ми повертаємося до назви лекції. Spring Boot не можна нормально зрозуміти без IoC і DI не тому, що «так написано в підручнику», а тому, що Boot — це зручна оболонка навколо системи, яка створює, зберігає та повʼязує обʼєкти. Якщо ви не розумієте, що означає «повʼязувати обʼєкти залежностями», будь-яка поведінка Boot починає виглядати як: «ну воно якось знайшло мій клас», «ну воно якось підставило залежність», «ну воно якось запустило сервер». А нам потрібна спокійна інженерна модель: «обʼєкти створюються і зʼєднуються за правилами, а не на удачу».
У нашому catalog-service це особливо важливо, тому що проєкт спеціально зроблено маленьким і без бази даних: ми не хочемо відволікатися на JPA або Security. Нам потрібно побачити скелет застосунку: як сервіс залежить від репозиторію, як контролер (пізніше) залежить від сервісу, як конфігурація (пізніше) керує поведінкою. І весь цей скелет тримається на DI.
7. Типові помилки під час IoC і DI
На початку шляху помилки майже завжди однакові, бо мислення ще «консольне»: є main, є new, є «поїхали». Це нормально, але важливо швидко навчитися помічати, де такий підхід починає заважати. Помилки нижче — не про «ви поганий розробник», а про те, де саме ламається керованість коду, коли проєкт перестає вміщатися в голову.
Помилка №1: плутати IoC і DI та вважати, що це одне й те саме.
IoC — це принцип «керування не всередині вашого коду, а ззовні». DI — це конкретна техніка «залежності передаються ззовні». Коли ви кажете «у нас IoC, бо ми передаємо залежність у конструктор», ви насправді описуєте DI. IoC — ширший: він про те, хто керує життям компонентів і хто кого викликає.
Помилка №2: думати, що DI — це просто конструктор з одним параметром.
Початківець часто вважає, що сама наявність конструктора з параметром робить код «красивим і правильним». Але DI — не косметика. DI корисний, коли ви справді переносите створення залежностей назовні і перестаєте ховати їх усередині класу. Якщо ви залишили new всередині, а зверху просто додали конструктор «для вигляду», ви не отримали переваг — ви отримали лише додатковий шум.
Помилка №3: оголосити DI, але все одно створювати залежності всередині класу «про всяк випадок».
Дуже популярний гібрид: частина залежностей приходить у конструктор, а частина створюється через new, бо «так простіше». Це робить поведінку класу непередбачуваною: ззовні здається, що залежність можна замінити, а всередині вона все одно фіксована. Якщо залежність важлива — або вона повністю зовнішня, або чесно визнаємо, що це внутрішня деталь і вона не має бути компонентом системи.
Помилка №4: перетворювати DI на релігію інтерфейсів «на все підряд».
Інтерфейс корисний, коли у вас є реальна причина для замінності — наприклад, різні джерела даних або заглушка для тестів. Але інтерфейс заради інтерфейсу — це просто додатковий шар слів. У маленькому проєкті можна починати з конкретних класів, а інтерфейси додавати там, де ви справді відчуваєте: «мені потрібно підміняти реалізацію».
Помилка №5: робити конструктор величезним і не помічати, що клас став «комбайном».
DI робить залежності явними. Це чудово… поки ви раптом не виявили конструктор на 10 параметрів. Зазвичай це симптом того, що клас робить занадто багато і час розділити відповідальність. DI тут як рентген: проблему не створює, але чесно її показує.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ