1. Типізовані властивості ContextFlow
На цей момент у нас уже є всі деталі окремо: значення надходять із properties як рядки, спільний ConversionService може стати контейнерним перекладачем типів, а для доменних випадків у нас є власні правила конвертації. Тепер завдання практичне — зібрати все це в ContextFlow, щоб рядки остаточно зупинилися на межі конфігурації, а сервіси працювали тільки з типами.
У нашому проєкті це особливо помітно на двох налаштуваннях: який канал сповіщень вважати типовим і в якому форматі генерувати звіт. Обидва вибори природно живуть у вигляді enum, тому що варіантів небагато, а нам важливо, щоб IDE та компілятор не дозволяли писати в них довільний текст.
Ось мінімальна карта того, що ми типізуємо:
| Налаштування | Ключ у properties | Було в коді (погана звичка) | Стане в коді (нормально) |
|---|---|---|---|
| Типовий канал сповіщень | contextflow.notifications.default-channel | String | NotificationChannel |
| Формат звіту | contextflow.report.format | String | ReportFormat |
Якщо знову підтягнути сюди String, повернеться весь знайомий набір: valueOf(), trim(), toUpperCase() і пізні помилки вже всередині сервісів. Тому далі збираємо варіант, де конфігурація залишається текстом тільки зовні, а всередину застосунку входять NotificationChannel і ReportFormat.
2. ReportFormat і контракт у properties
Перед тим як писати конвертери, важливо зробити річ, яку розробники часто пропускають: домовитися про типи й про те, які значення взагалі допустимі. Із NotificationChannel нам простіше — він уже частина домену, а варіанти очевидні: EMAIL, SMS, CONSOLE. А ось формат звіту ми сьогодні оформимо явно, щоб перестати зберігати в конфігу загадкові рядки на кшталт "text" або "csv-like" і не гадати, що автор мав на увазі.
Додамо enum ReportFormat. За структурою проєкту логічно покласти його в com.example.contextflow.domain.model (або поруч із частиною звітності, якщо у вас так склалося), тому що це поняття використовуватиметься і в сервісному шарі, і в конфігурації.
public enum ReportFormat {
// Рівно перелічені значення — це і є «контракт» конфігурації.
TEXT,
CSV
}
Тепер давайте визначимося, які значення ми хочемо бачити в contextflow.properties. Якщо залишити все за замовчуванням, то багато вбудованих enum-конвертерів очікують точного збігу. Тобто для CSV потрібно написати CSV, а не csv. Але в реальному житті люди пишуть csv, тому що вони не зобов’язані пам’ятати Java-угоди про верхній регістр і, чесно кажучи, не повинні.
Тому наш «контракт» на рівні зовнішнього конфігу буде м’якшим: ми дозволяємо csv, CSV, csv — а Spring акуратно приводить це до ReportFormat.CSV ще на межі конфігурації.
Ось як це може виглядати в src/main/resources/contextflow.properties:
# Зовні — звичайний текстовий конфіг.
# Ми спеціально пишемо в нижньому регістрі, щоб показати «мʼякий» контракт.
contextflow.notifications.default-channel=sms
contextflow.report.format=csv
Зверніть увагу на дрібницю: файл залишається простим текстом. Жодної магії. Магія (у хорошому сенсі) буде в тому, що рядок не доживе до сервісного шару як рядок.
3. Спільний ConversionService
Тут ми нічого не пишемо заново з нуля. У ContextFlow нам потрібні два шари правил конвертації.
Для NotificationChannel залишаємо окремий StringToNotificationChannelConverter: для каналу сповіщень корисно мати власне правило нормалізації й зрозуміле повідомлення про помилку саме для цього доменного типу.
Для звичайних enum-випадків на кшталт ReportFormat достатньо StringToEnumIgnoringCaseConverterFactory: вона покриває повторюваний шаблон trim -> upper -> Enum.valueOf(...) і не змушує клонувати один і той самий converter під кожен enum.
Залишилося зареєструвати обидва механізми в одному conversionService, який контейнер використовуватиме під час зв’язування.
import com.example.contextflow.support.conversion.StringToEnumIgnoringCaseConverterFactory;
import com.example.contextflow.support.conversion.StringToNotificationChannelConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.support.DefaultConversionService;
@Configuration
public class ConversionConfig {
@Bean
public ConversionService conversionService() {
DefaultConversionService service = new DefaultConversionService();
// Загальне правило для звичайних enum-випадків, наприклад ReportFormat.
service.addConverterFactory(new StringToEnumIgnoringCaseConverterFactory());
// Спеціальне правило для NotificationChannel, якщо хочемо окрему валідацію та повідомлення про помилку.
service.addConverter(new StringToNotificationChannelConverter());
return service;
}
}
Ось обіцяна схема: як саме проходить значення:
flowchart TD
A["contextflow.properties (рядки)"] --> B["${...} placeholder"]
B --> C["ConversionService (рядок -> тип)"]
C --> D["Settings bean (NotificationSettings/ReportingSettings)"]
D --> E["Service layer (працює з enum)"]
Сенс у тому, що сервіси — це остання зупинка, де взагалі мають існувати тільки типи, а не «сирий текст із середовища».
4. Settings-beans і @Value
Тепер ми робимо важливий архітектурний крок: увесь «бруд» конфігурації — ключі, placeholder-и, значення за замовчуванням — ми тримаємо в спеціальних settings-beans. Це не магія і не окремий рівень архітектури на кшталт “enterprise”, а просто дисципліна: якщо завтра ключ перейменується, ви зміните його в одному місці, а не шукатимете по всьому проєкту ${contextflow....} як розробницькі пасхалки.
NotificationSettings
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class NotificationSettings {
// Тут уже живе тип, а не рядок.
private final NotificationChannel defaultChannel;
public NotificationSettings(
// Значення за замовчуванням корисне для локального старту без повного конфігу.
@Value("${contextflow.notifications.default-channel:CONSOLE}")
NotificationChannel defaultChannel) {
this.defaultChannel = defaultChannel;
}
public NotificationChannel defaultChannel() {
// Назовні віддаємо типізоване значення — сервіси не знають про ключі та рядки.
return defaultChannel;
}
}
Зверніть увагу на дві деталі. Ми залишили значення за замовчуванням CONSOLE, щоб проєкт міг стартувати навіть із мінімальним конфігом: це зручно для локальних запусків. І найголовніше: у полі та назовні в нас не рядок, а NotificationChannel.
ReportingSettings
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class ReportingSettings {
// Типізоване налаштування формату звіту.
private final ReportFormat format;
public ReportingSettings(
// Значення за замовчуванням: якщо ключ не задано, використовуємо TEXT.
@Value("${contextflow.report.format:TEXT}") ReportFormat format) {
this.format = format;
}
public ReportFormat format() {
return format;
}
}
Значення після двокрапки тут — це fallback тільки на випадок, коли ключ взагалі відсутній. Це зручно для локального старту, поки конфіг ще не заповнений повністю. Але якщо ключ є і в ньому написано telegram або csvv, конвертація все одно впаде: default не скасовує правило «невідоме значення = помилка конфігурації».
Тепер у нас є дві маленькі, чесні, легко тестовані сутності. Вони не розв’язують бізнес-завдання, не надсилають сповіщення і не пишуть звіти. Їхня робота — бути межею, на якій конфігурація перестає бути строковим болотом і стає типізованою реальністю.
5. Сервіси без парсингу рядків
На цьому кроці відбувається те, заради чого ми взагалі все це починали: сервісний шар починає виглядати як нормальний Java-код, а не як “код для виживання рядків”. У хорошому сервісі ви хочете бачити switch по enum, компіляторну перевірку варіантів і зрозумілу точку помилки при зламаному конфігу.
Реєстр відправників за каналом
Щоб сервісу було зручно отримувати відправника за NotificationChannel, зробимо простий реєстр. Це можна тримати як @Component в інфраструктурі.
import java.util.Map;
import org.springframework.stereotype.Component;
@Component
public class NotificationSenderRegistry {
// Зв’язка "канал -> конкретна реалізація відправника".
private final Map<NotificationChannel, NotificationSender> senders;
public NotificationSenderRegistry(
// Конкретні реалізації приходять із Spring як біни.
EmailNotificationSender email,
SmsNotificationSender sms,
ConsoleNotificationSender console) {
// Явно мапимо enum на реалізацію — це простіше й надійніше, ніж рядки.
this.senders = Map.of(
NotificationChannel.EMAIL, email,
NotificationChannel.SMS, sms,
NotificationChannel.CONSOLE, console
);
}
public NotificationSender byChannel(NotificationChannel channel) {
// Тут важливо: якщо каналу немає в мапі — це інфраструктурна проблема конфігурації бінів.
return senders.get(channel);
}
}
Так, тут ми явно пов’язуємо канал із реалізацією. І це нормально: канал — доменна сутність, реалізації відправників — інфраструктура, і реєстр якраз і служить мостом між ними.
NotificationDispatchService без рядків
import org.springframework.stereotype.Service;
@Service
public class NotificationDispatchService {
private final NotificationSettings settings;
private final NotificationSenderRegistry registry;
public NotificationDispatchService(NotificationSettings settings,
NotificationSenderRegistry registry) {
this.settings = settings;
this.registry = registry;
}
public NotificationSender defaultSender() {
// Жодних valueOf/trim/toUpperCase: ми вже на боці типів.
return registry.byChannel(settings.defaultChannel());
}
}
У цьому сервісі більше немає valueOf, trim, toUpperCase. Він працює з типами та зрозумілими об’єктами. Виклик defaultSender() або поверне коректного відправника, або, якщо ви забудете зареєструвати щось у реєстрі, дасть вам null, і ви швидко побачите проблему — уже на рівні «інфраструктурна карта неповна», а не «конфіг якийсь дивний».
Вибір ReportFormatter за ReportFormat
Формат звіту — глобальне налаштування. Отже, нам зручно створити один bean ReportFormatter, який вибирається в конфігурації на старті.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ReportingConfig {
@Bean
public ReportFormatter reportFormatter(ReportingSettings settings,
TextReportFormatter text,
CsvReportFormatter csv) {
// Вибір реалізації робимо один раз на старті за типізованим налаштуванням.
return settings.format() == ReportFormat.CSV ? csv : text;
}
}
Ця маленька конструкція робить одразу дві корисні речі. По-перше, вона гарантує, що весь ReportingService працює з єдиним formatter-ом. По-друге, вона переносить вибір реалізації до конфігураційного шару, а не в бізнес-сервіс. І ви знову не бачите в сервісах жодного рядка на кшталт "csv".
6. Fail-fast під час помилок конфігурації
Найприємніше в типізованій конфігурації — не те, що “код красивіший”. Найприємніше — що помилка конфігурації проявляється одразу, під час старту контейнера. Це називається fail-fast: якщо застосунок не може чесно працювати, він має відмовитися запускатися, а не чекати моменту, коли клієнт уже натиснув кнопку “Створити замовлення”.
Уявімо, що хтось — зазвичай це «хтось» на кшталт вас через тиждень, і ви ж потім будете сваритися — написав у contextflow.properties так:
contextflow.notifications.default-channel=telegram
Контейнер спробує створити NotificationSettings. Для цього він має впровадити NotificationChannel. Для цього він викличе наш StringToNotificationChannelConverter. Конвертер чесно скаже: “не знаю такого каналу”, і кине IllegalArgumentException. Підсумок — контекст не стартує, і ви бачите проблему там, де їй і місце: на межі конфігурації.
Якщо ви захочете швидко побачити цю поведінку наживо, можна зробити маленьку перевірку в main() (якщо у вас є точка входу), просто щоб переконатися, що застосунок падає на старті, а не «десь потім»:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ContextFlowApplication {
public static void main(String[] args) {
// Ідея проста: якщо конфіг зламаний, контекст не підніметься.
try (var ctx = new AnnotationConfigApplicationContext(ContextFlowAppConfig.class)) {
System.out.println("Контекст запущено!"); // Не виведеться при зламаному конфігу.
}
}
}
Зламаний конфіг — і ви навіть не побачите “Контекст запущено!”. Це хороший біль: швидкий, чесний і в правильному місці.
7. Типові помилки під час типізації конфігурації
Після такої лекції зазвичай хочеться негайно типізувати взагалі все підряд, включно з ім’ям кота в properties. Це нормальний ентузіазм, але його варто тримати під контролем. Типізація конфігурації справді робить проєкт міцнішим, але разом із цим вимагає дисципліни: де конвертувати, де зберігати ключі, як обробляти помилки і як не затягнути бізнес-логіку в шар conversion. Давайте розберемо найчастіші промахи, які трапляються саме на цьому кроці.
Помилка №1: “Сервісу простіше, я прямо тут розпарсю рядок”.
Зазвичай це починається з одного рядка valueOf(), а закінчується тим, що у вас у трьох сервісах три різні версії «нормалізації»: десь trim(), десь toUpperCase(), десь ще й replace("-", "_"). Конфігурація перетворюється на лотерею: трохи змінили формат — і одна частина застосунку живе, а інша падає. Лікується це просто: усе, що може бути типом, має стати типом на межі конфігурації, у settings-bean і через ConversionService.
Помилка №2: “Давайте при невідомому значенні мовчки візьмемо default”.
Це дуже підступна річ: застосунок стартує, усе «наче працює», але насправді працює не так, як ви хотіли. Якщо в конфігу написали sms, а ви раптом вирішили, що невідоме значення — це CONSOLE, ви можете тижнями не помічати, що сповіщення йдуть не туди. Для навчального і для звичайного backend-застосунку краще правило «невідоме значення — це помилка». Нехай контейнер падає одразу, зате ви розумієте, що система не погоджується з конфігом.
Помилка №3: conversionService зареєстрували, але назвали інакше.
У Spring є кілька точок «convention over configuration». Одна з них — ім’я conversionService. Якщо ви назвали bean, наприклад, myConversionService, він як об’єкт існуватиме в контексті, але контейнер не буде автоматично використовувати його як загальний механізм конвертації для @Value і binding. У підсумку ви будете дивитися на коректний код і думати, чому він не працює. Це той випадок, коли ім’я має значення, і воно має бути простим: conversionService.
Помилка №4: конвертер починає вирішувати за бізнес-код.
Іноді хочеться зробити в конвертері «розумно»: якщо прийшло невідоме значення, вибрати найкращий канал; якщо прийшло sms, але SMS вимкнено, вибрати email; якщо прийшло csv, але «сьогодні у нас свято», вибрати text. Усе це звучить весело, але це вже не conversion. Конвертер — перекладач, а не менеджер продукту. Щойно він починає вибирати поведінку, ви втрачаєте передбачуваність і перетворюєте старт застосунку на шоу «вгадай, що я мав на увазі».
Помилка №5: ключі properties розмазано по всьому коду.
Сьогодні ми зробили settings-beans саме для того, щоб ключі жили в одному місці. Якщо за пару днів ви знову почнете писати @Value("${contextflow.report.format}") прямо в ReportingService, ви відкотите дисципліну назад. Налаштування мають залишатися налаштуваннями: одна точка входу, типізоване значення назовні, жодного «прямого доступу до сирого конфігу» з бізнес-шару.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ