1. Роль конвертерів і форматерів
Якщо ви раніше писали контролери «в лоб», то, найімовірніше, бачили два неприємні сценарії: або параметри контролера перетворюються на String-зоопарк, і ви вручну парсите все підряд, або ви ставите доменні типи, але застосунок починає вередувати через дрібниці на кшталт регістру літер чи дефісів. Конвертери й форматери вирішують саме цю побутову проблему: вони дозволяють залишити контролер типізованим і чистим, а правила перетворення рядків зібрати в одному місці.
Із датами все працює просто: для LocalDate вистачило spring.mvc.format.*, і Java-код майже не знадобився. Але track=java-backend — це вже не стандартний тип дати чи часу. MVC знає, що прийшов рядок, але не знає нашої доменної домовленості, тож тут потрібні власні правила.
Уявімо наш фільтр каталогу. Ми хочемо, щоб API приймав запит на кшталт:
GET /api/catalog/courses?track=java-backend&level=basic&launchedAfter=2026-01-01
З погляду людини це нормальний URL: малі літери, дефіс, ISO-дата. А з погляду Java-enum CourseTrack це насправді JAVA_BACKEND, і якщо все залишити «за замовчуванням», Spring часто очікує рядок точно як імʼя enum-константи. Тобто вам би довелося писати:
track=JAVA_BACKEND&level=BASIC
А це вже схоже на API для роботів, які не люблять людей. Ми ж хочемо, щоб «людські» значення приймалися спокійно, а в контролері все залишалося типізованим.
Щоб зрозуміти, де ми втручаємося, корисно тримати в голові дуже спрощену схему:
flowchart TD A["URL: track=java-backend"] --> B["MVC-зв’язування"] B --> C["ConversionService"] C --> D["CourseTrack.JAVA_BACKEND"] D --> E["Параметр методу контролера: CourseTrack track"]
Наше завдання сьогодні — навчити ConversionService розуміти «java-backend» так само впевнено, як «JAVA_BACKEND».
2. Вбудоване MVC-зв’язування без коду
Перед тим як писати власні конвертери, важливо не потрапити в класичну пастку початківця: «Я не знаю, як працює Spring, отже, напишу все сам». Spring MVC уже вміє перетворювати величезну кількість типів, і часто правильний крок — нічого не писати, а лише правильно оголосити тип параметра в контролері. Інакше можна влаштувати собі мініпроєкт «напишемо Spring усередині Spring» — це весело рівно перші пʼять хвилин, а потім стає сумно.
boolean, int і багато інших примітивів та обгорток Spring конвертує без нашої участі. Це означає, що такі фільтри, як featuredOnly, publishedOnly і limit, можна спокійно тримати типізованими вже зараз:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
class DemoController {
@GetMapping("/demo")
String demo(@RequestParam(defaultValue = "false") boolean featuredOnly,
@RequestParam(defaultValue = "20") int limit) {
// Spring сам сконвертує рядкові query-параметри у boolean/int
// defaultValue спрацює, якщо параметр узагалі не передали
return "featuredOnly=" + featuredOnly + ", limit=" + limit;
}
}
Із часовими типами логіка така сама: LocalDate, LocalTime і LocalDateTime уже покриваються штатним MVC-механізмом, якщо ви один раз задали формат через spring.mvc.format.*. Отже, правило просте: спочатку беремо готову поведінку для вбудованих типів і дат, а власний код додаємо лише там, де в запиті з’являється доменний словник.
Саме так і відбувається з track і level. Це вже не просто int і не стандартний тип дати чи часу, тому далі нам потрібні власні Converter і Formatter.
3. Converter vs Formatter: різні задачі
На цьому місці зазвичай виникає питання: «Добре, я зрозумів, що є якийсь ConversionService, але чому в Spring аж два інтерфейси — Converter і Formatter? Хіба не можна було обійтися одним і не плутати людей?». Можна було, але тоді Spring втратив би зручний розподіл обов’язків. Converter — це просто і прямолінійно: «перетвори тип S у тип T». Formatter — це більш «людський» інтерфейс: він уміє і розібрати рядок у тип, і надрукувати тип назад у рядок, ще й з урахуванням локалі.
Давайте зафіксуємо різницю у вигляді невеликої таблиці, тому що таблиці — це легальна форма списку, яку навіть @EnableWebMvc не вимкне:
| Механізм | Напрямок | Типовий кейс у MVC | Локаль | Коли обирати |
|---|---|---|---|---|
| Converter<S, T> | String → T | query params, path variables: "java-backend" → CourseTrack | ні | коли потрібно просто «зрозуміти» вхідні дані |
| Formatter<T> | String ↔ T | форми, друк/розбір, коли потрібен стабільний текстовий формат | так | коли важливі обидва напрями: і parse, і print |
У нашому catalog-service запити приходять як рядки, і нам потрібно отримати доменні типи. Тому Converter<String, CourseTrack> — ідеальна перша сходинка.
А Formatter<CourseLevel> корисний, коли ви хочете мати стійкий текстовий формат у двох напрямах. Навіть якщо зараз ви переважно парсите вхід, наявність print(...) часто дисциплінує: ви заздалегідь фіксуєте, як цей тип має виглядати в URL у нормальному вигляді.
Важливо ще одне: і Converter, і Formatter мають бути маленькими, детермінованими та без бізнес-логіки. Їхнє завдання — перетворення форми, а не ухвалення рішень. Якщо ви спіймали себе на думці «а давайте в конвертері звернемося до сервісу, подивимося, чи є такий track у базі», то ви вже робите не conversion, а суміш валідації й бізнес-логіки. І це буде дуже важко налагоджувати.
4. CourseTrackConverter: String → CourseTrack
Коли ми приймаємо enum через query-параметр, справжній біль не в самому enum, а в тому, що користувачі, та й ви самі через тиждень, писатимуть значення по-різному. Хтось напише basic, хтось BASIC, хтось junior-plus, а хтось узагалі junior_plus. І все це «майже одне й те саме», але компʼютер — істота принципова й образлива.
Тому ми вводимо просте, зрозуміле правило нормалізації рядка. Для наших enumʼів воно буде таким: прибираємо пробіли по краях, приводимо до верхнього регістру та замінюємо дефіси на підкреслення. Це рівно той формат, який «любить» Enum.valueOf(...).
Мініутиліта — її можна тримати просто всередині конвертера чи форматера, щоб не плодити зайві класи:
private static String normalizeEnumKey(String raw) {
// Нормалізуємо вхід із URL до формату enum-констант: BASIC, JAVA_BACKEND тощо
if (raw == null) return null;
return raw.trim() // прибираємо пробіли по краях
.toUpperCase() // приводимо до верхнього регістру
.replace('-', '_'); // дефіси в URL перетворюємо на underscore в enum
}
Так, це виглядає як «три рядки заради двох символів». Але саме такі три рядки перетворюють API з того, що ламається через регістр, на систему, яка працює нормально. І далі ви це правило використовуєте в обох місцях: і в Converter, і в Formatter.
Тепер зробімо перший реальний доменний конвертер. Логіка проста: на вхід приходить рядок із URL, на вихід — CourseTrack. Якщо рядок порожній або складається з пробілів, ми повернемо null, щоб фільтр не застосовувався (параметр у нас required = false). Якщо рядок не відповідає жодній enum-константі — кидаємо виняток, і MVC поверне 400.
import org.springframework.core.convert.converter.Converter;
class CourseTrackConverter implements Converter<String, CourseTrack> {
@Override
public CourseTrack convert(String source) {
// Цей метод викличе Spring, коли він зв’язуватиме String із URL у CourseTrack
// реалізація нижче
return null;
}
}
Додамо нормальну реалізацію. Зверніть увагу на два моменти: ми обробляємо «порожнечу» і ми даємо зрозумілий виняток. Це не «повний error-contract», а просто людське повідомлення замість «десь щось зламалося».
import org.springframework.core.convert.converter.Converter;
class CourseTrackConverter implements Converter<String, CourseTrack> {
@Override
public CourseTrack convert(String source) {
// Якщо параметр не передали або він порожній — фільтр не застосовуємо
if (source == null || source.isBlank()) return null;
// Нормалізуємо зрозумілий для людини формат (java-backend) у enum-ключ (JAVA_BACKEND)
String key = source.trim().toUpperCase().replace('-', '_');
// Якщо ключ не збігається з жодною константою — valueOf кине IllegalArgumentException,
// а Spring MVC поверне 400 (помилка конвертації вхідних даних)
return CourseTrack.valueOf(key);
}
}
Якщо ви зараз думаєте: «А valueOf не небезпечний? Він кидає IllegalArgumentException», — так, кидає. І в цьому випадку це нормально: Spring MVC перехопить помилку конвертації й поверне 400. На рівні курсу Boot нам цього достатньо, окремий error-layer тут не потрібен.
Щоб ви відчули різницю в поведінці, уявіть дві реальності:
— У реальності без нашого конвертера користувач зобов’язаний знати внутрішні імена enumʼів, і запит track=java-backend падає.
— У реальності з конвертером запит track=java-backend перетворюється на CourseTrack.JAVA_BACKEND і спокійно доїжджає до сервісу, а контролер залишається чистим.
І ще одне маленьке, але важливе правило: конвертер не має тягнути залежності. Він не повинен бути «розумним» і вирішувати, що саме мав на увазі користувач. Нормалізація — окей. Словник із сотнею синонімів і залежністю від репозиторію — уже ні, принаймні в межах нашої поточної навчальної архітектури.
5. CourseLevelFormatter: String ↔ CourseLevel
Із CourseLevel ми могли б зробити точно такий самий Converter<String, CourseLevel> — і в багатьох проєктах так і роблять. Але за планом дня нам важливо побачити, що Formatter — це двосторонній контракт: він не лише парсить вхід, а й може друкувати об’єкт назад у рядок за нашим стандартом. Це корисно, коли ви десь формуєте посилання, текстові представлення або просто хочете єдиний формат того, як це виглядає зовні.
Щоб optional-фільтри поводилися симетрично, домовімося ще про одне правило: порожній level трактуємо так само, як відсутність параметра. Тобто ?level= не вмикає «особливий режим», а просто не застосовує фільтр. Усе, що після нормалізації не збіглося з enum-константою, і далі лишатиметься помилкою клієнта.
Мінімальна реалізація форматера:
import java.util.Locale;
import org.springframework.format.Formatter;
class CourseLevelFormatter implements Formatter<CourseLevel> {
@Override
public CourseLevel parse(String text, Locale locale) {
// Порожнє значення фільтра трактуємо так само, як відсутність параметра
if (text == null || text.isBlank()) return null;
// parse: розбираємо рядок із запиту і перетворюємо його на enum
String key = text.trim().toUpperCase().replace('-', '_');
return CourseLevel.valueOf(key);
}
@Override
public String print(CourseLevel object, Locale locale) {
// print: друкуємо об'єкт назад як рядок у "канонічному" форматі
// locale впливає на перетворення регістру (наприклад, у деяких мовах є особливості)
return object.name().toLowerCase(locale);
}
}
Так, метод print(...) зараз може здаватися зайвим. Але він фіксує важливу річ: якщо нам потрібно показати рівень у вигляді рядка, наприклад десь поруч із API, документацією чи логікою формування посилань, ми хочемо отримати єдиний «канонічний» формат. У прикладі вище він буде basic, junior_plus (якщо просто toLowerCase(...)), і це вже краще, ніж BASIC усюди.
Якщо ви хочете зовсім «симпатично», можна друкувати з дефісами. У межах лекції покажу коротко, як це виглядає:
@Override
public String print(CourseLevel object, Locale locale) {
// Робить зовнішній формат зручнішим для URL: junior_plus -> junior-plus
return object.name().toLowerCase(locale).replace('_', '-');
}
Це не обовʼязковий крок, але він показує ідею: форматер — це не лише про parse, а й про те, як ми домовилися показувати цей тип назовні.
6. Реєстрація в WebMvcConfigurer
Писати конвертери й форматери у вакуумі безглуздо: поки ви їх не зареєстрували, MVC про них навіть не здогадується, так само як більшість людей — про вашу мотивацію вчитися вечорами. Правильне місце реєстрації в Boot-проєкті — ваш WebConfiguration, який реалізує WebMvcConfigurer, і конкретно метод addFormatters(...).
Мінімально це виглядає так:
import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfiguration implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
// Реєструємо власні правила поверх автоконфігурації Spring Boot
registry.addConverter(new CourseTrackConverter());
registry.addFormatter(new CourseLevelFormatter());
}
}
Зверніть увагу: ми не ставимо @EnableWebMvc. Ми не вимикаємо автоналаштування Boot. Ми просто додаємо маленькі правила поверх уже працюючого MVC.
І ще важливий нюанс: тут ми створюємо конвертери через new, тому що вони без стану й не потребують залежностей. Це нормально. Якщо ж ви захочете зробити конвертер складнішим, наприклад із налаштуваннями, тоді вже доречно оформити його як @Bean і інжектити потрібне — але це крок, який варто робити лише за реальної потреби.
7. Типи прямо в контролері
Найприємніший момент у всій цій історії — ви перестаєте писати «рядковий» код у контролері. Контролер починає виглядати так, ніби вхідні дані вже в порядку, і вам лишається лише передати їх далі до сервісу. Це і є те саме «тонкий controller, товстий сервіс», тільки без фанатизму.
Приклад сигнатури для нашого /api/catalog/courses після додавання конвертації:
import java.time.LocalDate;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
class CourseCatalogController {
@GetMapping("/api/catalog/courses")
List<CourseCard> findCourses(@RequestParam(required = false) CourseTrack track,
@RequestParam(required = false) CourseLevel level,
@RequestParam(required = false) LocalDate launchedAfter,
@RequestParam(defaultValue = "20") int limit) {
// На момент входу в метод: Spring уже сконвертував String із URL у ваші доменні типи
// limit прийде як int (або значення defaultValue, якщо параметр не передали)
return List.of(); // тут буде виклик service.find(...)
}
}
Зверніть увагу, що в цьому методі немає підготовчої роботи. Жодних toUpperCase() і replace('-', '_') у контролері. Контролер не знає, що користувач міг надіслати java-backend, він просто отримує CourseTrack.JAVA_BACKEND. Це дуже добре розділення відповідальності.
Тепер запити, які ви можете надіслати в браузері або Postman, стають значно дружнішими:
GET /api/catalog/courses?track=java-backend&level=basic&limit=5
Якщо ви хочете додати прапорці, вбудоване зв’язування теж не підведе:
GET /api/catalog/courses?track=spring&featuredOnly=yes&limit=3
Так, yes для boolean теж часто працює завдяки вбудованому String → Boolean конвертеру в Spring, і це приємний бонус, який багато хто не помічає, доки не спробує.
А якщо користувач надіслав некоректні дані:
GET /api/catalog/courses?track=totally-not-a-track
Spring не зможе перетворити рядок на enum і поверне 400. У межах нашого поточного курсу це цілком коректна поведінка: ми не будуємо великий error-contract, але й не вдаємо, що все добре.
8. Типові помилки під час роботи з конвертерами і форматерами
Найчастіше конвертери й форматери ламаються не через складність, а через надмірну жадібність: розробник починає запихати в них більше логіки, ніж потрібно, і перетворює простий шар перетворення на мінібізнес-двигун. Друга популярна проблема — неправильне місце реєстрації, через що ви написали ідеальний конвертер, а MVC про нього так і не дізнався. Конвертер сумує, контролер парсить рядки вручну, і світ стає трохи менш прекрасним.
Помилка № 1: робити перетворення прямо в контролері.
Це виглядає нешкідливо: «Ну я ж усього лише зробив toUpperCase()». Але потім ви додасте другий endpoint, третій, і раптом у вас десять місць, де потрібно «підправити» формат. Конвертер і форматер потрібні саме для того, щоб такі правила жили централізовано.
Помилка № 2: писати конвертер навіть для того, що Spring і так уміє.
Якщо ви зробили LimitConverter для int або FeaturedOnlyConverter для boolean, ви, найімовірніше, ускладнили проєкт без користі. Вбудоване звʼязування — ваш друг. Власне — лише там, де реально не вистачає «з коробки».
Помилка № 3: конвертер залежить від CourseCatalogService або репозиторію.
Щойно в конвертері зʼявляється сервіс, він перестає бути перетворенням і стає бізнес-логікою в неочікуваному місці. Це робить застосунок складнішим для розуміння і може призвести до циклічних залежностей. Конвертер має бути простим, без DI і без побічних ефектів.
Помилка № 4: не нормалізувати вхід і розраховувати на ідеального користувача.
У реальному житті люди пишуть SPRING, spring, Spring, spring і навіть spring-boot там, де ви очікували SPRING. Якщо ви не нормалізували вхід, хоча б trim + uppercase + replace, ви отримаєте «випадкові» 400 помилки, які виглядають як нестабільність API.
Помилка № 5: плутати глобальне форматування дат і форматери чи конвертери для доменних типів.
spring.mvc.format.date=iso впливає на те, як розбираються LocalDate і споріднені типи. А ваші enum-конвертери — це інша історія, вони не «візьмуться з YAML самі собою». Хороша новина: обидва механізми чудово живуть поруч, якщо ви тримаєте їх у правильних межах.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ