1. Обмеження вбудованої конвертації
ConversionService уже дав нам загальний механізм, але вбудованої конвертації String -> enum вистачає лише доти, доки значення з конфігу збігається з іменем enum-константи. Щойно людина пише sms замість SMS або залишає пробіли, суворе правило перестає допомагати. Саме тут і потрібен власний Converter: одне місце, де ми нормалізуємо вхідні дані й робимо помилку явною ще на старті.
Уявімо, що в contextflow.properties у нас є налаштування:
# Значення в конфігу надходить рядком і його легко записати в будь-якому регістрі
contextflow.notifications.default-channel=sms
А доменна модель у нас чесна й сувора:
import com.example.contextflow.domain.model.NotificationChannel;
// У коді ми хочемо працювати з доменним типом, а не з рядком
NotificationChannel channel = NotificationChannel.SMS;
Проблема в тому, що Java-enum за замовчуванням чутливі до регістру. Ось такий код виглядає невинно, але падає через один неправильний регістр:
// У конфіг надійшло "sms", а enum очікує суворо "SMS"
String raw = "sms";
NotificationChannel channel = NotificationChannel.valueOf(raw); // IllegalArgumentException
Якщо в цей момент почати розсипати trim().toUpperCase() по сервісах, ми просто повернемо рядковий парсинг назад у бізнес-шар. Нам потрібен інший хід: сервіси й далі працюють із NotificationChannel, а правило нормалізації та валідації живе в окремому Converter<String, NotificationChannel> на межі конфігурації.
2. Інтерфейс Converter<S, T>
На цьому етапі дуже важливо не переплутати: Converter — це не «якийсь магічний Spring-клас», а досить приземлена ідея. Це маленький перекладач: він бере значення одного типу й повертає значення іншого. Не більше. Якщо ваш Converter почав писати у файли, ходити в мережу або вибирати біни за профілем — вітаю, ви випадково збудували міні-монстра.
У Spring інтерфейс виглядає так (спрощено за змістом):
// S — тип вхідного значення, T — тип результату
public interface Converter<S, T> {
// Єдина відповідальність: взяти S і перетворити на T
T convert(S source);
}
Літери S і T — це generics, тобто «тип джерела» і «тип результату». Для нашої задачі:
- S буде String (бо з properties прилітає текст),
- T буде NotificationChannel (бо сервісам потрібен доменний тип).
І тут корисна практична думка: хороший Converter зазвичай чиста функція. Це означає, що якщо ви двічі сконвертуєте "sms" — ви двічі отримаєте один і той самий результат. Він не повинен залежати від часу, випадковості, зовнішніх сервісів і настрою кота.
Ще одне важливе правило для новачка: Converter — не місце для «розумних здогадок». Якщо конфіг неправильний, краще впасти на старті з зрозумілою помилкою, ніж «мовчки вибрати щось за замовчуванням». Тому що «мовчки» майже завжди означає «потім хтось буде мовчки плакати в логах».
3. StringToNotificationChannelConverter
Зараз ми зробимо рівно те, заради чого прийшли: напишемо конвертер, який приймає рядки на кшталт " sms " і "Sms", а повертає чесний NotificationChannel.SMS. Важливо: ми робимо це не для краси, а щоб конфіг був терпимішим до людського введення, але при цьому залишався суворо перевірюваним.
Для початку нагадаємо собі, як виглядає наш enum. Скоріше за все, він у вас уже є в ContextFlow, але корисно побачити, що саме ми конвертуємо:
// Доменний тип: список допустимих каналів сповіщень
public enum NotificationChannel {
EMAIL, SMS, CONSOLE
}
Тепер пишемо конвертер. Я покажу варіант, який робить три речі: перевіряє порожнечу, нормалізує регістр і дає зрозумілу помилку.
import com.example.contextflow.domain.model.NotificationChannel;
import org.springframework.core.convert.converter.Converter;
public class StringToNotificationChannelConverter
implements Converter<String, NotificationChannel> {
@Override
public NotificationChannel convert(String source) {
// Порожнє або пробільне значення — це помилка конфігурації
if (source == null || source.isBlank()) {
throw new IllegalArgumentException("Канал сповіщень порожній");
}
// Нормалізуємо введення: прибираємо пробіли та приводимо до верхнього регістру
return NotificationChannel.valueOf(source.trim().toUpperCase());
}
}
Зверніть увагу на дві деталі, які дуже «по-дорослому» впливають на якість застосунку.
Перша деталь — isBlank(). У конфігу інколи випадково залишають порожнє значення: ключ є, а значення не дописали. Для нас це помилка конфігурації, а не «ну гаразд, нехай буде null».
Друга деталь — ми не робимо «запасний варіант». Тобто ми не кажемо: «якщо що — візьмемо CONSOLE». Такий підхід маскує проблему: конфіг неправильний, але застосунок продовжив роботу в непередбачуваному режимі. Якщо це навчальний проєкт — ви просто не помітите, що щось пішло не так. Якщо це production — помітите, але пізно й у поганому настрої.
Якщо вам хочеться підтримати псевдоніми на кшталт stdout → CONSOLE, це теж робиться тут, але акуратно. Наприклад, так:
// Приклад підтримки псевдоніма: це все ще про формат вхідного рядка, а не про бізнес-логіку
String v = source.trim().toUpperCase();
if (v.equals("STDOUT")) v = "CONSOLE";
return NotificationChannel.valueOf(v);
Головне — не перетворювати конвертер на «бізнес-правила». Псевдоніми — це про формат вхідного рядка, а не про вибір стратегії сповіщень.
4. Реєстрація в ConversionService
Найчастіший сценарій новачка такий: «Я написав конвертер… і нічого не змінилося». І це логічно: Spring не телепат. Йому потрібно явно сказати: «Ось загальний механізм конвертації, і ось ще одне правило для нього».
Ми будемо використовувати DefaultConversionService (як у минулій лекції) і додамо наш конвертер у нього. Важливо: bean має називатися conversionService, щоб контейнер підхопив його як глобальну інфраструктуру.
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() {
// Глобальний сервіс конвертації типів для всього ApplicationContext
DefaultConversionService service = new DefaultConversionService();
// Додаємо правило: String -> NotificationChannel
service.addConverter(new StringToNotificationChannelConverter());
return service;
}
}
Тут відбувається важлива «контейнерна магія», але магія пояснювана. На старті ApplicationContext побачить bean з іменем conversionService і зрозуміє: «О, це головний перекладач типів». Після цього, коли Spring намагатиметься впровадити значення з @Value у параметр конструктора типу NotificationChannel, він звернеться до цього ConversionService.
Якщо дуже схематично, ланцюжок виглядає так:
flowchart TD
A["contextflow.properties: 'sms'"] --> B["@Value('${...}') повертає String"]
B --> C["ConversionService"]
C --> D["NotificationChannel.SMS"]
D --> E["впровадження через конструктор у бін"]
І це саме той момент, коли «рядок із файлу» перетворюється на «тип у коді» до входу в бізнес-логіку.
5. Використання з @Value і NotificationChannel
Коли конвертер зареєстровано, найприємніше — побачити результат у коді. Ми зробимо невеликий bean для налаштувань, який логічно тримати в інфраструктурному шарі, наприклад у infrastructure.configsupport. Він зберігає типізований конфіг.
import com.example.contextflow.domain.model.NotificationChannel;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@Component
public class NotificationSettings {
private final NotificationChannel defaultChannel;
public NotificationSettings(
// Spring читає рядок із properties і конвертує його в enum через ConversionService
@Value("${contextflow.notifications.default-channel}") NotificationChannel defaultChannel
) {
this.defaultChannel = defaultChannel;
}
public NotificationChannel getDefaultChannel() {
return defaultChannel;
}
}
Зверніть увагу, що тут немає ані trim(), toUpperCase(), ані valueOf(). І це не тому, що ми «ліниві». Це тому, що ми свідомо винесли конвертацію туди, де їй і місце.
Тепер можна зробити маленьку діагностичну перевірку під час старту. Наприклад, у вашому ScenarioRunner або в окремому ранері, який ви й так піднімаєте з контексту:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {
// Перевіряємо, що типізоване налаштування справді піднялося з конфігу
NotificationSettings s = ctx.getBean(NotificationSettings.class);
// Очікуємо побачити SMS, навіть якщо в конфігу написано "sms"
System.out.println("Канал за замовчуванням = " + s.getDefaultChannel()); // Канал за замовчуванням = SMS
}
Якщо в contextflow.properties стоїть sms, ви побачите SMS. Це добрий знак: отже, конвертер справді бере участь у процесі.
І ось тепер важливий архітектурний бонус: ваш сервіс, який займається надсиланням сповіщень, може залежати не від рядка, а від NotificationChannel. Навіть якщо всередині у вас маршрутизація через switch (що в навчальному проєкті нормально), вона стане чистою і типобезпечною.
6. Розміщення конвертера в проєкті
Дуже хочеться покласти StringToNotificationChannelConverter поруч із NotificationChannel «тому що це ж пов’язано». Але це як покласти викрутку в пачку макаронів: зв’язок є (обидва на кухні), але користуватися незручно, і сенс шарів розмивається.
NotificationChannel — частина домену. Це смислова модель: які канали сповіщень існують у системі.
StringToNotificationChannelConverter — частина інфраструктури Spring, бо він розв’язує технічну задачу: як перетворити рядок із конфігурації на доменний тип.
Тому в ContextFlow розумно тримати конвертер у пакеті на кшталт:
- com.example.contextflow.support.conversion (як і запропоновано в ТЗ проєкту)
А конфігурацію, яка збирає шар конвертації, у:
- com.example.contextflow.config.core (або близько до core wiring)
Тоді виходить читабельна картина: домен чистий, Spring-механізми ізольовані, а налаштування конвертації не «протікають» у сервіси.
І ще один важливий момент: конвертер — це частина глобального механізму. Якщо ви почнете робити десять різних ConversionService по кутах застосунку, ви втратите передбачуваність. В одному місці конвертація працює так, в іншому — інакше. Зазвичай це закінчується словами «чому в одному біні нормально, а в іншому падає?!». Відповідь буде проста: «тому що ви самі собі зробили два різні світи».
7. Неправильне значення: fail-fast на старті
Давайте спеціально «зламаємо» конфіг. Поставимо, наприклад:
# Навмисно неправильний канал — щоб побачити fail-fast під час старту контексту
contextflow.notifications.default-channel=telepathy
Якби в нас був ручний парсинг у бізнес-методі, застосунок міг би спокійно стартувати, а впасти лише тоді, коли хтось уперше спробує надіслати сповіщення. Це неприємно: помилка проявляється пізно, неочевидно, а інколи взагалі в іншому потоці виконання (якби ми колись додали асинхронність).
З конвертером відбувається інше: Spring намагається створити NotificationSettings, бачить @Value(...) NotificationChannel, запускає конвертацію, і наш конвертер кидає IllegalArgumentException. У підсумку контекст не підніметься.
Так, звучить суворо. Але насправді це поведінка «як інженер любить»: fail-fast. Ми виявили помилку на межі конфігурації, у момент старту, коли ще не відбулося жодної бізнес-дії.
Важливо розуміти: у stack trace ви побачите не лише IllegalArgumentException, а й «обгортки» Spring — наприклад, щось на кшталт “failed to convert value” або “unsatisfied dependency”. Не лякайтеся. Це нормальне «пакування» помилки: Spring показує, який bean не зміг створити і чому.
Практична порада тут проста: коли ви бачите таку помилку, спочатку шукайте внизу причину (Caused by), а потім підіймайтеся вище й дивіться, який bean і яке поле або параметр впали. За кілька днів практики ви будете читати такі помилки майже так само швидко, як повідомлення месенджера — тільки менш приємно, але зате корисно.
8. Типові помилки під час роботи з конвертерами
Помилка № 1: конвертер починає ухвалювати бізнес-рішення.
Якщо всередині convert() ви раптом вибираєте, «який NotificationSender використовувати» або «яку стратегію знижки ввімкнути» — ви змішали шари. Конвертер має перекладати типи, а не керувати поведінкою застосунку. Інакше ви отримаєте дивний дизайн, де конфіг і бізнес-логіка переплелися в одному методі, який викликає контейнер «десь там».
Помилка № 2: невідоме значення перетворюється на «дефолт тихцем».
Фраза «ну якщо незрозуміло — візьмемо CONSOLE» звучить турботливо, але це турбота із підступом. Ви маскуєте помилку конфігурації, а потім дивуєтеся, чому сповіщення «раптом перестали приходити на SMS». Краще впасти одразу. Помилка конфігурації має бути гучною, інакше вона буде дорогою.
Помилка № 3: забули про пробіли й регістр, і конвертер вийшов марним.
Якщо ваш конвертер робить рівно NotificationChannel.valueOf(source) — ви майже нічого не виграли порівняно з вбудованою поведінкою. Мінімальний сенс власного конвертера зазвичай у нормалізації (trim(), toUpperCase()) або в зрозумілій помилці. Інакше ви просто написали код заради коду.
Помилка № 4: convert() повертає null як «нормальний результат».
Технічно Converter може повернути null, і іноді це використовується (наприклад, порожній рядок → null). Але для обов’язкового налаштування на кшталт default-channel це майже завжди погана ідея: ви отримаєте null у settings-bean і потім NPE в неочікуваному місці. Якщо налаштування обов’язкове — краще викидати виняток.
Помилка № 5: конвертер зареєстрували не в загальний conversionService, а «десь локально».
Часто роблять так: створили new DefaultConversionService() всередині одного сервісу й додали конвертер туди. У цьому місці все працює… а в інших — ні. У підсумку виходить «магія» вже вашого власного виробництва. Тримайте один спільний ConversionService bean і додавайте правила туди.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ