1. URL-значення й body: два всесвіти
Зазвичай плутанина виникає так: ви бачите в контролері LocalDate, UUID, TaskStatus, і мозок автоматично клеїть ярлик «це ж конверсія, отже HttpMessageConverter». Але Spring MVC улаштований простіше й чесніше: він дивиться, звідки ви хочете отримати значення. Із URL — отже це рядок, із body — отже це потік байтів, який треба прочитати за Content-Type.
Після роботи з JSON легко почати бачити HttpMessageConverter взагалі в будь-якій конверсії контролера. Але щойно дані надходять із path або query, жодного JSON-документа там немає: у Spring в руках звичайний рядок, і працює вже інший механізм.
В HTTP-запиті є різні частини, і технічно вони живуть у різних місцях. @PathVariable і @RequestParam витягують дані з URL (path і query string). Там завжди текст, навіть якщо ви передали туди число, дату або UUID. Spring спочатку отримує рядок, а потім уже намагається перетворити його на потрібний Java-тип. І саме це перетворення робить не HttpMessageConverter.
@RequestBody — зовсім інша історія. Там немає «готового рядка значення», там є тіло повідомлення (bytes). Його треба прочитати, вибрати відповідний формат (за Content-Type), розпарсити (наприклад, JSON), зібрати об’єкт, обробити помилки структури JSON і невідповідності полів. Для цього й існує HttpMessageConverter (а всередині JSON-сценарію — Jackson).
Якщо запам’ятати одну фразу, то ось вона: URL-параметри — це «рядок → тип», body — це «байти → об’єкт». Схоже? Трохи. Але механізми різні, налаштування різні, помилки різні — і саме тому важливо їх не змішувати.
Щоб зафіксувати картину, давайте намалюємо дві паралельні доріжки обробки:
flowchart TD
A[HTTP-запит] --> B[URL: шлях + query]
A --> C[HTTP-тіло]
B --> B1["рядки: 'TODO', '2026-03-21', '3fa85f...'"]
B1 --> B2["ConversionService / форматери"]
B2 --> B3["значення @PathVariable / @RequestParam"]
B3 --> D[Виклик методу контролера]
C --> C1["байти + Content-Type: application/json"]
C1 --> C2[HttpMessageConverter]
C2 --> C3["Jackson 3: десеріалізація JSON -> об’єкт"]
C3 --> D
Зверніть увагу на важливу деталь: і там, і там усе відбувається до входу в метод контролера. Просто до цього в Spring працюють два різні механізми попередньої обробки.
2. @RequestParam і @PathVariable: ConversionService
Коли ви проєктуєте API, query і path-параметри здаються «просто рядками в URL». Але Spring MVC хоче, щоб ви писали контролери типобезпечно: int, boolean, LocalDate, UUID, enum. Тому йому потрібен механізм, який уміє перетворювати рядок на потрібний тип, — і він робить це централізовано, а не через ваші ручні Integer.parseInt(...) у кожному методі.
За це відповідає окремий механізм Spring, який зазвичай називають type conversion. На базовому рівні корисно знати два імені: ConversionService і форматери. ConversionService — це «головний перекладач» рядкових значень у Java-типи. Форматери — це окремі помічники для типів, де важливий формат, наприклад для дат, часу або чисел у певному вигляді. Усередині Spring MVC ці механізми використовуються, коли аргументи методу беруться з URL: @RequestParam, @PathVariable і також @ModelAttribute (коли ви збираєте багато query-параметрів в один об’єкт критеріїв).
Найпрактичніший наслідок такий: якщо ви змінюєте щось у JSON-налаштуваннях, наприклад те, як Jackson читає дати, це не змінює того, як розбираються дати з query string. І навпаки: якщо ви підкрутили формат для query-параметрів, це не означає, що JSON body автоматично почне прийматися в тому самому форматі. Ці налаштування працюють окремо.
Щоб у вас не склеювалося в голові «усе це один конвертер», давайте порівняємо дві системи в одній таблиці:
| Що ми перетворюємо | Звідки надійшло | Приклад на вході | У що перетворюємо | Хто відповідає | Який тут зв’язок Content-Type / Accept |
|---|---|---|---|---|---|
| Query/path-значення | URL (рядки) | status=TODO, dueBefore=2026-03-21, /tasks/3fa8... | TaskStatus, LocalDate, UUID | ConversionService + форматери | Зазвичай ні до чого: URL не залежить від типу медіаданих |
| Тіло запиту/відповіді | HTTP body (байти) | JSON-текст у body | Java-об’єкт / JSON-текст | HttpMessageConverter (для JSON — з Jackson) | Ключовий чинник: за Content-Type обирається читання, за Accept / produces — запис |
Якщо хочеться метафору, і вона справді допомагає, то HttpMessageConverter — це перекладач «з байтів» на «зміст» (об’єкт), а ConversionService — перекладач «з папірця» (рядок) на «зміст» (число/дата/enum). Обидва — перекладачі, але один не зобов’язаний уміти робити роботу іншого.
3. Приклад: query-параметри в Task Tracker
Зараз ми візьмемо наш Task Tracker API і навмисно додамо в endpoint для списку пару параметрів, які за змістом виглядають «ніби однакові»: статус (enum) і дедлайн (дата). Це хороший приклад, тому що в майбутньому в задачах справді з’являться фільтри, але вже зараз ми можемо побачити, яким механізмом Spring перетворює рядки з URL на типи.
Уявімо, що в нас уже є enum статусу задачі (у проєкті він усе одно буде, тому що статус — частина домену). У контролері ми хочемо вміти викликати щось на кшталт GET /api/v1/tasks?status=TODO&dueBefore=2026-03-21. Значення TODO і 2026-03-21 надходять рядками, а Spring має конвертувати їх у TaskStatus і LocalDate.
Ось мінімальний навчальний фрагмент методу (уявіть, що він знаходиться в TaskController):
import java.time.LocalDate;
import com.example.tasktracker.domain.model.TaskStatus;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@GetMapping("/api/v1/tasks")
public String find(
@RequestParam TaskStatus status, // Spring перетворить рядок "TODO" на enum через ConversionService
@RequestParam
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) // Явно фіксуємо очікуваний формат дати в URL
LocalDate dueBefore) {
// Тут уже готові типи: до методу дійшли лише після успішної конверсії з рядка
return status + " до " + dueBefore; // TODO до 2026-03-21
}
Тут відбуваються дві маленькі магії, але обидві — не про HttpMessageConverter. TaskStatus отримується з рядка TODO через механізм конверсії простих типів. LocalDate отримується з рядка 2026-03-21, а анотація @DateTimeFormat явно підказує, що ми очікуємо ISO-дату. Навіть якщо ви цю анотацію приберете, у багатьох конфігураціях Boot дата й без цього парситиметься як ISO; але в навчальних прикладах корисно явно показувати намір, щоб потім не гадати, що «саме так і працювало».
Запит для перевірки можна покласти в .http файл або виконати в Postman:
GET http://localhost:8080/api/v1/tasks?status=TODO&dueBefore=2026-03-21
Accept: text/plain
# Accept впливає на формат відповіді, а не на розбір query-параметрів
Зверніть увагу на Accept: text/plain: він впливає лише на те, як ми писатимемо відповідь. На читання query-параметрів він узагалі не впливає. Це ще один спосіб утримати в голові, що query-параметри живуть у своїй площині.
Тепер зробимо експеримент, який у реальному житті трапляється частіше, ніж хотілося б: надішлемо дату «по-людськи», а не за ISO.
GET http://localhost:8080/api/v1/tasks?status=TODO&dueBefore=21.03.2026
Accept: text/plain
# Тут ламається саме URL-конверсія рядка в LocalDate (не Jackson і не HttpMessageConverter)
Що буде? Spring спробує сконвертувати рядок 21.03.2026 у LocalDate. Якщо формат не збігається з очікуваним, запит не зможе пройти етап перетворення параметрів, і до методу контролера справа не дійде. Важливо, що JSON тут узагалі не брав участі: не було @RequestBody, не було Content-Type, не було вибору HttpMessageConverter.
Якщо вам хочеться буквально «помацати», чи дійшов запит до методу, можна на мить поставити найпростіший слід:
import java.time.LocalDate;
import com.example.tasktracker.domain.model.TaskStatus;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@GetMapping("/api/v1/tasks")
public String find(@RequestParam TaskStatus status,
@RequestParam
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE) // Якщо формат не збігся — метод навіть не викличеться
LocalDate dueBefore) {
System.out.println("find() викликано"); // Навчальна мітка: у реальному коді краще використовувати логер
return status + " до " + dueBefore;
}
Якщо дата в query не парситься, рядок у консоль не виведеться. Запит упаде раніше. Так, у нормальному проєкті ви використовуватимете логер, а не System.out.println, але для навчального «детектора входу в метод» це чесний і зрозумілий трюк.
4. Приклад: @PathVariable з UUID
Тепер візьмемо @PathVariable, бо з ним ситуація ще наочніша. У нашому проєкті ідентифікатор задачі — UUID, який у URL надходить рядком, наприклад /api/v1/tasks/3fa85f64-5717-4562-b3fc-2c963f66afa6. І тут у вас є вибір: приймати taskId як String (максимально прямолінійно) або як UUID (типобезпечно). Обидва варіанти мають сенс, але обидва зав’язані на конверсію рядка.
Давайте подивимося на варіант із UUID — він краще демонструє, що Spring робить конверсію простих типів без участі HttpMessageConverter. Тіло запиту відсутнє, Content-Type не потрібен, але Spring усе одно перетворює «шматок path» на об’єкт UUID.
import java.util.UUID;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/api/v1/tasks/{taskId}")
public String one(@PathVariable UUID taskId) {
// UUID береться з path як рядок і конвертується через ConversionService ще до входу в метод
return "taskId=" + taskId;
}
Тепер запит «хороший»:
GET http://localhost:8080/api/v1/tasks/3fa85f64-5717-4562-b3fc-2c963f66afa6
Accept: text/plain
# Body немає, Content-Type не потрібен: працює лише читання URL і конверсія рядка в UUID
А тепер запит «поганий»:
GET http://localhost:8080/api/v1/tasks/not-a-uuid
Accept: text/plain
# Помилка буде на етапі URL-конверсії: рядок "not-a-uuid" не перетворюється на UUID
У другому випадку Spring спробує сконвертувати рядок not-a-uuid у UUID. Не вийде — і знову запит упаде на етапі конверсії URL-значень, а не на етапі читання body (body тут узагалі немає).
Тут є важливий інженерний нюанс, який варто проговорити вголос. Якщо ви приймаєте taskId як String, то Spring завжди зможе його передати, бо рядок у рядок конвертується ідеально (у цьому механізмі це майже no-op). Але тоді перевірка коректності UUID «переїде» або у ваш сервіс, або в бізнес-логіку, або взагалі залишиться неявною. Якщо ж ви приймаєте UUID, то отримуєте ранню перевірку формату просто на межі API: некоректний ідентифікатор не вдаватиме «валідний» і не долітатиме до глибини застосунку.
У навчальному проєкті це корисно навіть суто психологічно: ви починаєте відчувати, що «межа API» — це не тільки метод контролера, а й уся підготовка аргументів до нього.
5. @RequestBody: HttpMessageConverter і Jackson
Зараз саме час зробити контрастний кадр: той самий LocalDate, але вже всередині JSON. Це той момент, де студенти найчастіше кажуть: «Ну ось, дата ж, отже Spring усе одно її якось конвертує». Так, конвертує. Але іншою підсистемою — через HttpMessageConverter і Jackson. І ця різниця важлива не тому, що «так написано в документації», а тому що ви потім по-різному лагодитимете проблеми.
Візьмімо спрощений create endpoint для задач. Ми приймаємо JSON, у якому є title і dueDate. dueDate всередині JSON — рядок, але це вже частина JSON-документа, а не query string.
import java.time.LocalDate;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
class CreateTaskBody {
public String title;
public LocalDate dueDate; // Це значення з JSON розбиратиме Jackson (а не ConversionService з URL)
}
@PostMapping(path = "/api/v1/tasks", consumes = "application/json")
public ResponseEntity<Void> create(@RequestBody CreateTaskBody body) {
// Сюди ми потрапляємо лише якщо HttpMessageConverter зміг прочитати body і Jackson зібрав об’єкт
return ResponseEntity.noContent().build();
}
Приклад запиту:
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json
# Content-Type критичний: за ним Spring обирає HttpMessageConverter для читання body
# Accept стосується формату відповіді, а не розбору вхідних даних
{
"title": "Виправити API",
"dueDate": "2026-03-21"
}
Тут усе працює тому, що ми чесно вказали Content-Type: application/json, а Spring зміг вибрати JSON-конвертер, який прочитає body і попросить Jackson зібрати CreateTaskBody. Якщо ви зміните Content-Type на невідповідний або надішлете битий JSON, проблема буде на боці перетворення body, тобто вже в області відповідальності HttpMessageConverter.
А тепер важлива думка: ви можете прийняти ISO-дату з query і з JSON body, але це не означає, що вмикається одна й та сама настройка. Формат дати в query регулюється форматерами й анотацією @DateTimeFormat. Формат дати в JSON регулюється налаштуваннями Jackson і тим, як Jackson обробляє LocalDate. Якщо ви колись бачили в проєкті ситуацію «в JSON дата читається, а в query — ні» або навпаки, то ось ви щойно отримали пояснення, чому так буває.
І ще один корисний «тест на тверезість»: спробуйте надіслати в create endpoint дату в не-ISO форматі, як ми робили в query ("21.03.2026"). Помилка знову буде «про дату», але це вже буде помилка десеріалізації JSON, а не помилка конверсії query-параметра. І ви не зможете виправити її додаванням @DateTimeFormat у контролері — тому що @DateTimeFormat стосується URL-значень, а не JSON.
6. Діагностика: URL чи body
На практиці перше діагностичне запитання тут дуже просте: дані надійшли з URL чи з body? Цього розгалуження часто вистачає, щоб одразу відкинути половину хибних гіпотез.
Якщо ламається @RequestParam, @PathVariable або @ModelAttribute, спочатку дивимося на формат рядка, ConversionService, форматери та анотації на кшталт @DateTimeFormat.
Якщо ламається @RequestBody, спочатку дивимося на Content-Type, форму JSON і відповідність JSON очікуваному Java-типу.
Цієї розвилки ще не вистачає, щоб покрити взагалі всі фази запиту. Окремо існують і mapping, і запис відповіді. Але вже на цьому кроці вона дуже добре захищає від типової помилки: лагодити Jackson там, де зламався query-параметр, або крутити форматери там, де проблема була в body.
7. Типові помилки під час роботи з URL і body
На цьому місці зазвичай хочеться сказати «ну все зрозуміло», а потім через кілька днів побачити в логах чергову помилку й знову запитати: «А це Jackson чи Spring?». Тому давайте закріпимо кілька граблів, на які наступають майже всі, включно з людьми, які клянуться, що «вони-то точно не наступлять». Спойлер: наступлять, просто в різних черевиках.
Помилка № 1: вважати, що будь-який LocalDate у контролері означає Jackson.
Якщо ви бачите LocalDate у параметрі методу й автоматично думаєте про JSON, зупиніться та запитайте себе, звідки взялося значення. LocalDate із query (@RequestParam) приходить рядком і конвертується через ConversionService. LocalDate з body (@RequestBody) читається через HttpMessageConverter і Jackson. Один тип — два різні світи.
Помилка № 2: намагатися лагодити query-параметри, змінюючи Content-Type або Accept.
Content-Type і Accept мають сенс тоді, коли є body або коли ви хочете контролювати формат відповіді. Query string не «стає JSON», якщо ви поставите Content-Type: application/json. URL лишається URL, а dueBefore=2026-03-21 лишається рядком. Не витрачайте час на заголовки там, де вони не беруть участі.
Помилка № 3: «зроблю формат дати красивішим для людей» — і зламаю контракт.
Хочеться приймати 21.03.2026, бо «так звичніше». Але для API це майже завжди шлях до хаосу: різні клієнти, різні локалі, різні очікування. ISO-формат (2026-03-21) не найромантичніший, зате детермінований і переносний. Якщо ви все-таки змінюєте формат, пам’ятайте, що окремо налаштовується конверсія query/path і окремо JSON-десеріалізація.
Помилка № 4: приймати ідентифікатор як String, а потім дивуватися дивним 404.
Коли taskId — рядок, запит із /tasks/not-a-uuid спокійно дійде до методу, а далі ви або отримаєте «не знайдено», або впадете десь глибше. Іноді це навіть нормально, але часто ви просто ховаєте «некоректний формат id» під семантикою «ресурс не знайдено». Якщо ви хочете, щоб формат перевірявся на межі, приймайте UUID (і пам’ятайте, що це знову ConversionService, а не Jackson).
Помилка № 5: плутати «конверсію значення» і «бізнес-логіку».
Якщо запит не дійшов до методу контролера, ваша бізнес-логіка тут ні до чого. Це важливо психологічно: не потрібно дебажити сервіси, репозиторії та «чому воно не знаходить задачу», якщо проблема була в dueBefore=21.03.2026. Спочатку завжди перевіряйте найзовнішній шар: шлях, query, headers, body — і лише потім глибину застосунку.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ