1. Невідомі поля
Якщо ви колись ловили себе на думці «ну, якщо JSON валідний, значить усе ок», то сьогодні доведеться цю думку обережно відпустити. У HTTP API коректність ділиться щонайменше на два шари: синтаксис (JSON можна розпарсити) і контракт (JSON відповідає очікуваній моделі). Невідоме поле — це як лист без помилок, але на чуже імʼя: поштар, звісно, старався, та лист усе одно незрозуміло куди класти.
Пошкоджений JSON — це коли JSON не можна прочитати в принципі: пропущено кому, лапку, зламана структура. Такий запит падає «на вході», бо парсер JSON не може побудувати дерево.
Невідоме поле — це коли JSON ідеально читається, але всередині є ключ, якого DTO не очікує. Тобто структура правильна, але в контракт ви додали «самодіяльність».
Давайте візьмемо наш звичний create-запит задачі. Припустімо, канонічний вхід виглядає приблизно так:
{
"title": "Fix pagination bug",
"description": "Reproducible on page=2"
}
А клієнт — або тестувальник, або «майбутній ви самі через два тижні» — надіслав ось так:
{
"title": "Fix pagination bug",
"description": "Reproducible on page=2",
"createdAt": "2026-03-21T10:15:00Z"
}
JSON коректний. Але createdAt — не поле вхідного DTO (і взагалі найчастіше це значення, яким керує сервер). Далі вже починається цікаве: сервер має вирішити, вважати це помилкою чи просто мовчки «вдати, що нічого не було».
Це інше завдання, ніж приховування відомих властивостей DTO через @JsonIgnore або @JsonIgnoreProperties({"..."}). Там властивість у типу вже існує, і ми вирішуємо, чи брати їй участь у JSON-контракті. Тут навпаки: клієнт приніс ключ, якого в DTO немає взагалі.
2. Де ловляться невідомі поля
Щоб не гадати на кавовій гущі, корисно бачити, на якому етапі відбувається перевірка невідомих полів. У MVC це стається на етапі читання request body — там, де працює HttpMessageConverter (для JSON це зазвичай конвертер на базі Jackson). А це означає, що невідомі поля — це не «логіка сервісу» і не «помилка бізнес-правила». Це питання десеріалізації вхідного JSON.
Зручно тримати в голові таку схему (дуже грубо, але чесно):
flowchart TD A["Тіло HTTP-запиту: JSON"] --> B["HttpMessageConverter"] B --> C["Jackson: JSON -> DTO"] C --> D["Метод контролера"] D --> E["Сервісний шар"]
У строгому режимі на етапі Jackson: JSON -> DTO відбувається приблизно таке: Jackson бачить ключ createdAt, шукає відповідне поле/аксесор/компонент record, не знаходить і каже: «Вибачте, я такого не замовляв». У підсумку Spring відповідає клієнту помилкою, найчастіше статусом 400 Bad Request.
Щоб побачити сам момент збою, візьмімо локальний фрагмент DTO, який очікує лише title. Нам тут потрібен саме конкретний цільовий тип: невідоме поле стає помилкою не «саме по собі», а в момент, коли Jackson намагається зібрати з JSON зрозумілий DTO.
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
// Локальний фрагмент DTO: для цього входу Jackson очікує лише поле title
record StrictTaskCreateRequestExample(String title) {}
@RestController // Навчальний контролер для демонстрації моменту, де ламається десеріалізація
public class DebugController {
@PostMapping("/api/v1/debug/tasks") // Точка входу має виконатися тільки якщо JSON вдалося зібрати в DTO
public String create(@RequestBody StrictTaskCreateRequestExample request) {
System.out.println("Контролерний метод виконано"); // За строгої обробки до цієї строки справа не дійде
return "ok";
}
}
Приклад і далі мінімальний: у реальному API тут був би звичайний request DTO, validation і сервісний шар. Але сам принцип видно чесно: якщо Jackson не може зібрати цільовий тип через невідомий ключ у строгій політиці, метод контролера не запускається.
З точки зору дисципліни API це круто: помилка ловиться максимально рано. З точки зору UX клієнта — іноді боляче: він надіслав «зайве поле», а сервер образився.
3. Строгий і терпимий вхід
У невідомих полів немає єдино правильної долі. Можна бути строгим і відхиляти запит. Можна бути терпимим і ігнорувати зайве. І обидва підходи можуть бути правильними — у різних ситуаціях. Тут ми як дорослі: обираємо не «що модніше», а «яку проблему ми розв’язуємо».
Щоб не перетворювати розмову на філософію, порівняймо підходи максимально приземлено:
| Стратегія | Як поводиться сервер | Переваги | Недоліки |
|---|---|---|---|
| Строгий вхід | «Є зайве поле — отже, запит не за контрактом, відповідаю помилкою» | Швидко ловить описки клієнта й неправильні очікування; контракт стає самодокументованим через помилки | Може ламати клієнтів під час мʼяких змін; іноді заважає інтеграціям, де дані «шумні» |
| Терпимий вхід | «Зайві поля не заважають — ігнорую, читаю лише відомі» | Зручний для мʼяких міграцій; стійкий до «шумних» клієнтів; менше падінь через несуттєві поля | Може приховувати помилки клієнта (описки, поле поклали не туди), особливо якщо поле необовʼязкове |
Найтиповіша дилема виглядає так. Клієнт зробив описку:
{ "assigneName": "Alice", "title": "..." }
Якщо вхід терпимий, assigneName просто буде проігноровано. Якщо assigneeName — необовʼязкове поле, запит пройде, але задача залишиться без призначеного виконавця, і ви потім будете шукати «чому у нас задачі порожні». Це не баг, це ваша стратегія «терпимості».
Якщо вхід строгий, сервер одразу відповість помилкою: «Невідоме поле assigneName». Клієнт виправить описку, і всім буде спокійніше.
З іншого боку, клієнт міг надіслати зайві поля не зі злого умислу, а тому що він просто взяв обʼєкт із відповіді (де є id, status, createdAt) і відправив його назад. Технічно це не за контрактом, але по-людськи зрозуміло. У терпимому режимі це працюватиме «ніби як», хоч і суперечливо.
4. Терпимий вхід: @JsonIgnoreProperties
Тепер перейдемо до головного практичного інструмента лекції. Якщо ви хочете сказати Jackson: «Коли читаєш JSON у цей DTO — зайві поля ігноруй», то найпростіший і найчитабельніший спосіб — анотація @JsonIgnoreProperties(ignoreUnknown=true) на request DTO.
Назва анотації тут знайома, але задача інша: ми не перелічуємо відомі властивості DTO, які треба сховати, а задаємо політику для ключів, яких у DTO взагалі немає.
У нашому проєкті Task Tracker API це зазвичай буде лежати в пакеті api.dto.request.
Візьмімо локальний фрагмент request DTO саме для цієї політики: полів тут мінімум, щоб було видно саме поведінку ignoreUnknown.
package com.example.tasktracker.api.dto.request;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true) // Кажемо Jackson: невідомі ключі в JSON не вважати помилкою
public record TaskCreateRequestTolerantExample(
String title, // Поля DTO — це і є контракт входу: читаємо лише те, що перелічено тут
String description,
String assigneeName
) {
}
Тепер якщо клієнт надішле:
{
"title": "Fix pagination bug",
"description": "Reproducible on page=2",
"createdAt": "2026-03-21T10:15:00Z",
"debugMode": true
}
то createdAt і debugMode будуть просто проігноровані, а DTO все одно успішно збереться. Ваш метод контролера отримає обʼєкт TaskCreateRequestTolerantExample(title=..., description=..., assigneeName=...) як ні в чому не бувало.
І ось тут важливо проговорити думку, яку часто пропускають: ignoreUnknown=true — це не «менше безпеки». Це про те, наскільки ви хочете бути “вчителем” для клієнта. Строгий режим — суворий учитель: «так не можна». Терпимий режим — добрий учитель: «гаразд, я зрозумів, що ви мали на увазі… мабуть».
5. @JsonAlias і терпимий вхід
Коли ми обговорювали @JsonAlias, ми говорили: alias — це місток для читання входу, але контракт усе одно повинен мати канонічну назву. У реальності alias часто йде в парі з терпимим входом, особливо коли клієнтські дані «різні й по-різному чудові».
Давайте покажемо акуратний, але реалістичний request DTO, який:
1) офіційно приймає assigneeName,
2) також читає assignee як alias,
3) і при цьому не падає від невідомих полів.
Тепер візьмімо інший локальний фрагмент: тут нам потрібно поєднати дві речі — alias для відомого поля і терпимість до зовсім зайвих ключів.
package com.example.tasktracker.api.dto.request;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
@JsonIgnoreProperties(ignoreUnknown = true) // Терпимо ставимося до зайвих ключів у JSON
public record TaskCreateRequestCompatibilityExample(
@JsonProperty("title") // Явно фіксуємо імʼя поля в JSON (канонічна назва контракту)
String title,
String description,
@JsonProperty("assigneeName") // Канонічна назва поля, яку ми показуємо в документації
@JsonAlias("assignee") // Альтернативна назва для читання входу (наприклад, для міграції/застарілих клієнтів)
String assigneeName
) {
}
Тепер можливі кілька варіантів входу.
Канонічний (те, що ми хочемо бачити в документації та в прикладах):
{ "title": "Write docs", "assigneeName": "Bob" }
Старий/альтернативний (вхід читається, але «офіційним» не стає):
{ "title": "Write docs", "assignee": "Bob" }
Шумний (вхід читається, зайве ігнорується):
{
"title": "Write docs",
"assignee": "Bob",
"id": "I-wish-I-could-set-it",
"createdAt": "yesterday",
"someWeirdFlag": true
}
Важливо, що @JsonAlias тут розвʼязує одне завдання (альтернативна назва для відомого поля), а ignoreUnknown=true розвʼязує інше (ігнорування невідомих полів). Ці механіки не конкурують — вони доповнюють одна одну.
6. Вибір стратегії для Task Tracker API
Тепер давайте приміряємо це на наш навчальний проєкт. У нас API — контрактний, JSON-first, і ми спеціально не хочемо перетворювати його на «приймач будь-яких JSON-документів на вході». Тому базова ідея проста: якщо немає вагомої причини терпіти зайве — краще бути строгими. Це заощаджує вам години відлагодження і робить поведінку сервера передбачуваною.
При цьому бувають випадки, коли терпимий вхід справді покращує життя. Наприклад, якщо у вас є інтеграція, яка стабільно надсилає «зайві поля», але при цьому надсилає і потрібні. У такому разі ігнорування може бути прагматичним компромісом: ви не ламаєте інтеграцію через шум, а контракт усе одно контролюєте через DTO (зайве ви не мапите в модель).
Ключовий момент саме в тому, що DTO захищає вас від over-posting. Навіть якщо клієнт надішле поле status або createdAt, у наш доменний обʼєкт воно не потрапить, бо в TaskCreateRequest такого поля немає. У терпимому режимі воно просто буде проігнороване. У строгому режимі — запит буде відхилено. Це не питання «безпечно/небезпечно» на рівні даних. Це питання «яку поведінку контракту ми хочемо».
І ще одне правило, яке несподівано часто рятує: якщо поле в request DTO необовʼязкове, терпимий вхід може перетворити звичайну описку клієнта на тихий баг. Строгий режим у таких місцях допомагає, бо ви ловите помилку там, де вона народилася, а не через тиждень у звіті «чому у нас assigneeName завжди null».
7. Демо: строгий і терпимий режим по HTTP
Щоб зафіксувати відчуття, корисно побачити це очима клієнта. Уявімо endpoint створення задачі (у нас він уже є за курсом) і два варіанти DTO: строгий і терпимий.
Щоб порівняти поведінку в чистому вигляді, знову візьмімо два маленькі фрагменти одного й того самого create-входу. Це не дві паралельні версії проєктного DTO, а одна й та сама вхідна точка з різною політикою читання JSON.
Варіант A: строгий DTO
package com.example.tasktracker.api.dto.request;
// Строгий варіант: будь-яке невідоме поле в JSON може перетворитися на помилку десеріалізації (залежно від налаштувань Jackson)
public record StrictTaskCreateRequest(
String title, // Контракт входу: очікуємо лише ці поля
String description
) {
}
Варіант B: терпимий DTO
package com.example.tasktracker.api.dto.request;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true) // Терпимий варіант: зайві поля в JSON ігноруються
public record TolerantTaskCreateRequest(
String title, // Читаємо лише відомі поля; "зайве" не потрапить у DTO
String description
) {
}
І ось .http запит, який клієнт може надіслати:
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json
{
"title": "Fix NPE",
"description": "Null happens in TaskMapper",
"createdAt": "2026-03-21T10:15:00Z"
}
У строгому режимі (якщо Jackson налаштовано так, що невідомі поля вважаються помилкою) сервер відповість помилкою, найчастіше це буде 400 Bad Request. У терпимому режимі запит спокійно пройде, а createdAt зникне, ніби його ніколи й не було. Приблизно як шкарпетка після прання: тільки ви памʼятаєте, що вона існувала, а реальність удає, що ні.
І ще раз важливе: терпимий вхід не означає, що сервер «прийняв createdAt». Сервер його проігнорував. Якщо ви хочете, щоб клієнт точно зрозумів, що він надіслав щось зайве, терпимий вхід — не ваш інструмент.
8. Типові помилки при строгому і терпимому вході
Помилки в цій темі зазвичай не про синтаксис анотацій, а про те, що розробник не до кінця усвідомив наслідки обраної стратегії. Тому я перелічу найчастіші граблі так, як вони виглядають у реальному проєкті — «вчора працювало, сьогодні дивно».
Помилка №1: увімкнути терпимий вхід “про всяк випадок” і потім шукати, чому помилки клієнта не ловляться.
Коли ignoreUnknown=true стоїть у request DTO без причини, сервер починає «терпіти» навіть банальні описки. Клієнт надіслав assigneName замість assigneeName — сервер промовчав, поле стало null, а баг сплив пізніше і не там, де його зручно виправляти. Якщо ви хочете дисципліну контракту, терпимість має бути рідкісним і усвідомленим винятком.
Помилка №2: вважати, що терпимий вхід повністю розвʼязує проблему міграції контракту.
Терпимість до невідомих полів справді допомагає, коли клієнт надсилає зайве. Але вона не допомагає, якщо клієнт надсилає старе поле, яке стало «відомим, але перейменованим». Тут потрібен @JsonAlias або інше явне рішення. Інакше ви просто ігноруєте дані, які клієнт намагався передати за змістом, і це ще гірше, ніж упасти з помилкою.
Помилка №3: використовувати терпимий вхід як «прихований ремонт» поганого DTO.
Іноді request DTO зроблено так, що клієнтам незручно з ним працювати, і вони постійно надсилають зайві поля (бо в них спільний обʼєкт для всіх операцій). Увімкнути ignoreUnknown — швидкий пластир, але якщо це стає нормою, це сигнал: контракт варто переглянути. Анотація має посилювати дизайн, а не маскувати те, що він незручний.
Помилка №4: увімкнути терпимість на response DTO і чекати, що це щось змінить.
Невідомі поля — це історія про читання вхідного JSON. На виході ви серіалізуєте DTO в JSON, і там просто не зʼявляться поля, яких немає. Тому @JsonIgnoreProperties на response DTO зазвичай або марна, або вводить в оману: створює відчуття, що ми “керуємо відповіддю”, хоча керуємо ми поведінкою десеріалізації.
Помилка №5: плутати невідомі поля зі зламаним JSON і намагатися «лікувати» все одним налаштуванням.
ignoreUnknown не врятує від пропущеної коми і не перетворить зламаний JSON на робочий. Він розвʼязує конкретну проблему: «у JSON є додаткові ключі, яких немає в DTO». Якщо у вас часто прилітає пошкоджений JSON, причина зазвичай у клієнті або в неправильному Content-Type, а не в Jackson-анотаціях.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ