1. Одна база — різні режими
Практично будь-який застосунок існує не в одному ідеальному світі, а в кількох. Ми запускаємо його локально, щоб швидко розуміти, що відбувається, показуємо демоверсію колегам або замовнику, а ще вмикаємо тестовий режим, де важливі детермінованість і відсутність побічних ефектів. Це не розкіш, а спосіб не зʼїхати з глузду вже на маленькому проєкті.
Візьмімо наш ContextFlow. У реальному житті нам дуже хочеться, щоб локальний запуск (dev) був максимально простим: усе видно в консолі, нічого не потребує файлових налаштувань, нічого не губиться. Для демо-режиму (demo) хочеться іншої поведінки: наприклад, писати аудит у файл, щоб залишалися артефакти, а сповіщення — консольними, щоб не залежати від зовнішніх систем. Для тестового режиму (test) хочеться ще суворіших правил: генерація ідентифікаторів має бути передбачуваною, сповіщення мають бути вимкнені або замінені на no-op, а файлові операції — максимально безпечними й такими, що не розповзаються по диску.
Щоб це відчувалося конкретно, спочатку зручно тримати в голові чернетковий ескіз режимів. Це не остаточна конфігурація, а чернеткова схема: зараз важливо побачити, що саме змінюється від режиму до режиму.
| Режим | Що важливо в цьому запуску | Якої поведінки очікуємо |
|---|---|---|
| dev | швидко бачити, що робить застосунок | максимум спостережуваності, мінімум клопоту |
| demo | показати процес і залишити артефакти | помітна поведінка плюс «відчутні» результати на кшталт файлу аудиту |
| test | відтворюваність і відсутність шуму | детерміновані значення та мінімум побічних ефектів |
Ключовий момент ось у чому: режими не мають змінювати сенс бізнес-процесу. Замовлення створюється й скасовується однаково. Змінюється лише те, як ми «спостерігаємо» процес і які зовнішні ефекти допускаємо в цьому режимі. Тобто мова не про іншу доменну логіку, а про різні інфраструктурні умови одного й того самого сценарію.
2. Поганий варіант: «режим» у бізнес-сервісі
Коли розробник уперше стикається з вимогою «потрібно три режими», найприродніший шлях — додати до бізнес-коду змінну mode і влаштувати карнавал if-else. Це виглядає швидко й навіть працює… рівно до того моменту, коли проєкт стає більшим за один клас. Після цього if-else починає розповзатися і, як будь-який живий організм, намагається вижити в кожному сервісі.
Уявіть, що ми зробили «розумний» сервіс сповіщень і навчили його враховувати режим роботи. На перший погляд зручно: у test просто нічого не надсилаємо. На другий погляд — сервіс почав залежати не лише від NotificationSender, а й від того, у якому режимі зібрано контейнер. Тобто всередині бізнесового шару зʼявилося знання про оточення.
import com.example.contextflow.domain.ports.NotificationSender;
public class NotificationDispatchService {
private final NotificationSender sender;
// Погано: сервіс починає знати про оточення (dev/demo/test), а не лише про доменне завдання
private final String mode;
public NotificationDispatchService(NotificationSender sender, String mode) {
// Важливо: sender — це правильна залежність (порт)
this.sender = sender;
// Погано: mode — це "прихована" конфігураційна залежність бізнесового шару
this.mode = mode;
}
public void notifyOrderCreated(String message) {
// "Тихий режим" у тестах виглядає зручно,
// але це змішує бізнес-логіку з режимами збирання застосунку
if ("test".equals(mode)) return;
sender.send(message);
}
}
Проблема тут не в самому if. Проблема в тому, що бізнес-сервіс раптово став залежним від профілю. Ви вже не можете прочитати конструктор і зрозуміти, що це за клас: він про відправлення сповіщень чи про умовне збирання оточень? А ще гірше те, що ви починаєте повторювати цю схему в інших місцях. І дуже швидко у вас зʼявляються if ("demo") в аудиті, if ("test") у генерації звіту, if ("dev") у логуванні… і ви повертаєтеся до тієї самої проблеми, з якої починали курс: прихованих залежностей і початкового збирання, що розповзається, тільки тепер це сховано всередині «звичайних» сервісів.
Є й тонша інженерна пастка. У якийсь момент режимів стає не три, а чотири або пʼять, і кожна нова гілка — це новий шанс випадково зламати поведінку. Код починає нагадувати ліс умов, а в лісі, як відомо, легко заблукати. Особливо якщо ви junior і у вас ще немає суперсили тримати великий контекст у голові (що нормально: мозок теж має scope і lifecycle).
3. Поганий варіант: if-else у коді збирання
Наступний природний крок після усвідомлення «не треба в бізнес-сервісах» — перенести if-else у конфігурацію. Це справді краще: принаймні бізнес-код знову стає «чистим», а вибір реалізації відбувається в одному місці. Але є нюанс: якщо ви робите умову всередині @Bean-методу, ви все одно починаєте кодувати режими як ручну логіку, а не як декларативну модель збирання контейнера.
Приклад часто виглядає так: ми читаємо властивість і залежно від неї повертаємо різну реалізацію.
import com.example.contextflow.domain.ports.AuditWriter;
import com.example.contextflow.infrastructure.audit.ConsoleAuditWriter;
import com.example.contextflow.infrastructure.audit.FileAuditWriter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
@Configuration
class AuditConfig {
@Bean
AuditWriter auditWriter(Environment env) {
// Читаємо налаштування з Environment (зазвичай з application.properties або application.yml)
String mode = env.getProperty("contextflow.audit.mode", "console");
// Важливо: вибір реалізації відбувається вручну за допомогою умови,
// тому конфігурація перетворюється на "міні-програму"
return "file".equalsIgnoreCase(mode) ? new FileAuditWriter() : new ConsoleAuditWriter();
}
}
Чому це все ще «не те», хоча формально працює? Тому що ми перетворюємо конфігурацію на міні-програму з розгалуженням і прихованою матрицею режимів. Сьогодні умова одна, завтра їх три, післязавтра ви починаєте залежати від комбінацій значень, і ваш конфігураційний шар перетворюється на маленький інтерпретатор умов. На цьому етапі у новачків часто зʼявляється думка: «А може, зробити один mega-config і в ньому 20 if-else?». Можна. Це навіть запускатиметься. Але читати це буде важче, ніж документацію до BeanFactoryPostProcessor (а ми туди ще навіть не дійшли).
І ще один важливий нюанс: коли ви вирішуєте через if-else всередині @Bean, контейнер як і раніше бачить один bean definition auditWriter. Він не бачить альтернативні варіанти як окремі сутності, які можна вмикати або вимикати. Через це діагностика та розуміння «яка саме конфігурація зараз активна» стають менш прозорими.
Тобто «умова в конфігурації» — це нормальний перехідний етап, особливо якщо у вас зовсім маленький проєкт, але нам хочеться більш масштабованої, контейнерної моделі. І ось тут на сцену виходять профілі.
4. Збирання з урахуванням профілів
Профіль у Spring — це спосіб сказати контейнеру: «У цьому режимі реєструй одні біни, а в іншому — інші». Важливе слово тут — реєструй. Профілі працюють не як «перемикач поведінки вже створеного обʼєкта», а як фільтр на етапі збирання ApplicationContext. Контейнер по суті отримує можливість збирати різні «комплектації» одного й того самого застосунку.
Корисно тримати в голові просту картину: спочатку Spring читає метадані про біни (BeanDefinition), потім вирішує, які з них узагалі беруть участь у збиранні, і лише після цього створює обʼєкти та звʼязує їх. Профілі втручаються саме в момент, коли контейнер вирішує, які визначення бінів брати в роботу.
Ось спрощена схема. Для цього місця нам потрібен лише один факт: фільтрація відбувається до створення обʼєктів.
flowchart TD
%% Профілі працюють на етапі, коли вирішується, які визначення бінів беруть участь у збиранні
A["Старт ApplicationContext"] --> B["Читання й реєстрація визначень бінів"]
B --> C{"Профіль підходить?"}
C -->|так| D["Залишаємо визначення біна"]
C -->|ні| E["Пропускаємо визначення біна"]
D --> F["Створюємо обʼєкти та звʼязуємо їх"]
F --> G["Застосунок готовий до роботи"]
Профілі — це відповідь на питання: як зробити так, щоб один і той самий застосунок міг мати різні інфраструктурні реалізації, не змушуючи бізнес-код знати про режими. Якщо ви дивилися на ескіз режимів вище, то профілі — це якраз той механізм, який дозволяє контейнеру в test не реєструвати «реальний» сповіщувач, а в demo підключити файловий аудит.
Поки що не переходимо до синтаксису @Profile; важливіше зафіксувати саму ідею: профіль — це характеристика збирання контейнера, а не стан доменної моделі. Профіль не має «жити» в Order.status і не має передаватися ланцюжком методів. Він живе в Environment контейнера і впливає на те, які біни взагалі зʼявляться в контексті.
5. Профілі й властивості
У новачків часто виникає плутанина: «Якщо ми вже вміємо читати налаштування з properties, навіщо нам ще профілі?». Відповідь проста: це різні рівні конфігурації. Properties задають значення, профілі змінюють склад застосунку. Це як різниця між «якого кольору машина» і «чи є в неї двигун, чи це велосипед». Обидва варіанти — про транспорт, але рівень рішення зовсім різний.
Подивімося на простий приклад property-конфігурації: у ReportOutputManager є шлях, куди писати звіти. Це значення справді зручно тримати в .properties і змінювати без перекомпіляції.
import org.springframework.beans.factory.annotation.Value;
public class ReportOutputManager {
private final String outputDir;
public ReportOutputManager(@Value("${contextflow.report.output-dir}") String outputDir) {
// Властивості (properties) налаштовують параметри вже вибраної реалізації
this.outputDir = outputDir;
}
}
Тут контейнер створює той самий бін, просто з різним значенням outputDir у різних оточеннях. І це чудово.
А тепер уявіть іншу задачу: у test ми взагалі не хочемо писати файли аудиту. Не «писати в інший каталог», а «не писати у файл як у концепцію». Ми хочемо замінити цілу реалізацію AuditWriter. Ось тут properties перетворюються на незграбний інструмент: ви намагатиметеся зробити contextflow.audit.mode=none, а далі писати if-else у AuditWriter або в конфігурації. Це знову повертає нас до пунктів 2–3.
Щоб не змішувати ці два світи, зручно тримати табличку-порівняння:
| Питання | Properties | Profiles |
|---|---|---|
| Що налаштовуємо? | значення всередині бінів | набір бінів та їхні реалізації |
| Коли впливає? | під час створення біна (впровадження значень) | до створення бінів (фільтрація реєстрації) |
| Де тримати логіку? | у конфігурації значень | у конфігурації збирання |
| Типовий приклад | шлях output-dir, імʼя застосунку | ConsoleAuditWriter vs FileAuditWriter, UuidIdGenerator vs Deterministic |
І тут є важливий висновок. Хороша конфігурація схожа на добре організовану кухню. Спеції (properties) лежать на полиці, і їх можна змінювати хоч щодня. А плита, духовка й мікрохвильова піч (profiles) — це вже «комплектація кухні»: ви не хочете щоразу збирати духовку зі спецій. Та й спеціями духовку не заміниш, як би ви не старалися.
6. Режими ContextFlow: dev, demo, test
Тепер давайте приземлимо все на наш проєкт без занурення в конкретні анотації. Зараз важливіше побачити, яку інженерну задачу профілі вирішують у ContextFlow і чому вони роблять це на рівні контейнера.
У ContextFlow у нас уже є правильні передумови: application-сервіси залежать від портів (інтерфейсів), а це означає, що ми можемо змінювати реалізації без переписування сервісів. Наприклад, NotificationDispatchService взагалі не зобовʼязаний знати, що в тестовому профілі сповіщення вимкнені. Він чесно викликає sender.send(...), а контейнер у test підставляє NoOpNotificationSender.
Ось як має виглядати такий сервіс у здоровому всесвіті:
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 залежно від профілю збирання
this.sender = sender;
}
public void notifyOrderCreated(String message) {
// Бізнес-метод залишається "чистим": жодних mode, env чи if-else, пов'язаних з оточенням
sender.send(message);
}
}
Зверніть увагу, чого тут немає: немає mode, немає Environment, немає «якщо test — повернись». Сервіс робить рівно свою роботу, а контейнер вирішує, який саме NotificationSender вважається «актуальним» у цьому режимі збирання.
Те саме з аудитом. AuditService або будь-який інший шар, який записує аудит, не має всередині себе логіки «у dev друкуй, у demo записуй у файл». Він має залежати від AuditWriter, а профіль визначає, який AuditWriter взагалі зареєстрований. У цьому сенсі профілі — це спосіб закріпити архітектурну дисципліну: бізнесовий шар не знає про оточення, оточення керує збиранням.
Для ContextFlow достатньо тримати в голові одну просту звʼязку: dev і demo потрібні для спостережуваності, test — для передбачуваності. На рівні портів це означає таке: у dev контейнер обирає максимально помітні реалізації на кшталт консольного аудиту та консольних сповіщень; у demo залишає ту саму простоту, але може зберігати артефакти на кшталт файлу аудиту в build/; у test підставляє детермінований OrderIdGenerator, no-op сповіщення та максимально безпечну поведінку щодо побічних ефектів.
Зверніть увагу, наскільки органічно профілі лягають на наш домен. Ми не вигадуємо штучну причину — ми описуємо реальні потреби розробки: зручність локального запуску, зручність демонстрації та зручність передбачуваних перевірок. Залишається оформити це як нормальне збирання на рівні контейнера: без if-else у сервісах і без перетворення конфігурації на умовний роман на 300 сторінок.
7. Типові помилки під час роботи з профілями
Помилка № 1: сприймати профіль як бізнесовий стан.
Коли profile починає передаватися через параметри методів або записуватися в доменні обʼєкти (order.setProfile("demo")), це сигнал, що межу порушено. Профіль — це не частина бізнес-логіки, а механізм конфігурації контейнера. Він має визначати, які біни створюються, а не як поводиться конкретне замовлення.
Помилка № 2: вирішувати режими через if-else усередині сервісів.
На перший погляд це просто: додали if (mode.equals("demo")) — і все працює. Але ви тим самим змішуєте бізнес-логіку з оточенням. Клас перестає бути прозорим: його поведінка залежить не лише від вхідних даних, а й від зовнішнього стану. DI якраз дозволяє винести цю умовність у збирання контейнера та залишити сервіси чистими.
Помилка № 3: робити профіль під кожне дрібне налаштування.
Коли зʼявляються profile=notifications-on, profile=notifications-off, profile=audit-file, profile=audit-console, система швидко стає некерованою. Профіль — це про режим роботи (набір бінів), а не про окремі прапорці. Дрібні відмінності мають жити в properties, а не роздувати кількість профілів.
Помилка № 4: змішувати profiles, properties та qualifiers без явної моделі.
Якщо частина рішень приймається через @Profile, частина через @Value, частина через @Qualifier, у якийсь момент стає незрозуміло, хто за що відповідає. Конфігурація перетворюється на «магічне місце», де все впливає на все. Хороше правило: профіль обирає реалізацію, properties налаштовують її поведінку.
Помилка № 5: очікувати, що профіль можна «перемкнути на льоту».
Профілі застосовуються на етапі ініціалізації ApplicationContext. Якщо ви розраховуєте, що можна змінити профіль під час роботи застосунку і все перебудується, — це неправильна модель. Для runtime-перемикань існують інші механізми (feature flags, properties), але не profiles.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ