1. Вступ
Коли ви вперше бачите, що @RestController «сам» віддає JSON, це сприймається як магія. Але насправді вона закінчується рівно там, де ви починаєте вимагати передбачуваності. У застосунку зазвичай багато ендпоїнтів, і якщо кожен віддаватиме JSON «як вийде», ви швидко отримаєте зоопарк форматів. Від нього стане сумно навіть вашим майбутнім тестам — а їм ще жити з вами.
Глобальні правила JSON — це домовленості, що мають діяти однаково в усьому застосунку. Наприклад, ви можете вирішити, що null-поля не потрібно надсилати клієнту, бо це просто зайвий шум. Або, навпаки, ви хочете завжди надсилати null, щоб контракт був максимально явний. Найважливіше: глобальне правило — це не «налаштування для одного місця», а політика всього застосунку.
Ми вже бачили механіку відповіді: контролер повертає об’єкт, MVC обирає HttpMessageConverter, Jackson формує JSON. Щойно ендпоїнтів стає більше ніж один, наступне запитання вже не «звідки взявся JSON», а «чому в усьому застосунку він має виглядати за одними й тими самими правилами».
Невелика схема, щоб не губитися в шарах:
flowchart LR
A["Контролер повертає
Java-об’єкт"] --> B["Spring MVC
HttpMessageConverter"]
B --> C["Jackson 3
JsonMapper (bean)"]
C --> D["Текст JSON"]
D --> E["HTTP-відповідь
Content-Type: application/json"]
Якщо ви змінюєте глобальні правила, змінюється поведінка JsonMapper, а отже — і серіалізація всіх відповідей, які проходять через цей mapper.
2. JsonMapper у Spring Boot
Дуже типова junior-логіка виглядає так: «Нам потрібен JSON — отже, я зараз створю JsonMapper через new і буду ним користуватися». І в цей момент Spring Boot тихо зітхає, тому що він уже все зробив за вас: за наявності Jackson у classpath Boot автоматично створює і налаштовує JsonMapper як bean усередині ApplicationContext.
Важливо вловити правильну думку: JsonMapper у Boot — це інфраструктура, а інфраструктура має бути єдиною й узгодженою. Якщо ви створите свій mapper «десь у кутку» (new JsonMapper()), у вас раптово зʼявляться два різні світи: у контролерах JSON формує один mapper, який створив Boot, а у вашому коді — інший, саморобний. Потім ви почнете порівнювати два JSON і думати, що Spring «зламався». Ні, ви просто випадково відкрили портал у паралельну реальність.
Якщо читатимете старі статті, часто натрапите на застарілий namespace com.fasterxml.... У нашій базовій лінії Spring Boot 4 + Jackson 3 використовуємо tools.jackson..., і всі приклади тут узгоджені саме з цією лінією.
Показовий мініприклад: ми не використовуємо JsonMapper для відповіді контролера, а просто переконуємося, що він існує як bean і серіалізує дані за тими самими правилами, що й web-шар.
import tools.jackson.databind.json.JsonMapper;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Map;
@Configuration
class JacksonDiagnosticsConfiguration {
@Bean
ApplicationRunner jacksonDiagnostics(JsonMapper jsonMapper) {
// Важливо: JsonMapper не створюємо через new — він приходить зі Spring-контексту.
return args -> {
// Діагностика: серіалізація через той самий mapper, який використовує web-шар.
System.out.println(jsonMapper.writeValueAsString(Map.of("status", "ok")));
// {"status":"ok"}
};
}
}
Зверніть увагу: JsonMapper приходить через DI. Це правильний «Boot-стиль»: інфраструктуру ми не створюємо, а використовуємо.
3. spring.jackson.* для спільних правил
Коли початківець хоче «підкрутити JSON», перша рука тягнеться писати @Configuration і збирати власний mapper. У багатьох випадках це передчасно: Spring Boot уже дає простий і читабельний шлях — властивості spring.jackson.* у application.yaml. Це саме той випадок, коли «спочатку конфігурація, потім код» — не модна філософія, а спосіб зберегти нерви.
Логіка така: ви змінюєте налаштування в YAML, Boot бачить їх на старті та застосовує до JsonMapper, який потім використовують і конвертори повідомлень, і будь-які ваші місця, куди ви (усвідомлено!) інжектите mapper. Виходить, що правило задається один раз і працює всюди однаково — без дублювання й без прихованих перевизначень у коді.
Мінімальний «скелет» налаштувань виглядає так:
spring:
jackson:
# Тут задаються глобальні правила серіалізації/десеріалізації для всього застосунку
# (це застосовується до JsonMapper, який використовує Spring MVC).
Далі ви додаєте конкретні прапорці. У цій лекції ми розберемо кілька таких прапорців, але без перетворення на «Jackson deep dive»: курс у нас про Spring Boot, а не про нескінченні налаштування серіалізації. Інакше ми ризикуємо ніколи не дійти до кінця курсу.
Невелика «карта місцевості» — чим відрізняються підходи:
| Що ви хочете змінити | Найчастіше правильний інструмент | Чому |
|---|---|---|
| Правило має діяти на всі JSON-відповіді | spring.jackson.* | Єдина політика, мінімум коду |
| Правило потрібне лише для одного типу (Money, наприклад) | точкове налаштування конкретного типу | Глобальні прапорці занадто широкі |
| Ви хочете «красивий» JSON лише для розробки | spring.jackson.* (обережно) | Швидко і видно всюди |
4. default-property-inclusion і null-поля
Майже всі починають з «ідеального» JSON, де в кожного курсу є всі поля. А потім реальність нагадує, що деякі речі бувають необов’язковими: у частини курсів може не бути shortDescription, тому що контент-менеджер ще не дописав текст або тому що в навчальному проєкті ви просто не хочете ускладнювати дані. І ось тут постає питання: якщо поле shortDescription == null, чи потрібно надсилати його в JSON?
За замовчуванням Jackson зазвичай серіалізує null-значення, і ви отримуєте щось на кшталт "shortDescription": null. Це не помилка, але це сигнал: ви як автор API кажете клієнту «поле існує, але зараз воно порожнє». Іноді це корисно, іноді — просто шум.
Якщо ви хочете, щоб null-поля не потрапляли до JSON, Boot дає дуже просте глобальне правило:
spring:
jackson:
# non_null: не включати поля зі значенням null до JSON-відповіді
default-property-inclusion: non_null
Тепер покажемо це на маленькому фрагменті, близькому до нашого catalog-service. Уявімо, що CourseCard уже є (як і в ТЗ проєкту), і shortDescription може бути null.
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class JsonNullDemoController {
@GetMapping("/api/catalog/demo/nulls")
CourseCard demo() {
// Демонстрація: shortDescription = null (у реальності поле може бути необов’язковим).
return new CourseCard("spring-boot", null);
}
}
Якщо default-property-inclusion не налаштовано, клієнт побачить JSON приблизно такого вигляду:
{"slug":"spring-boot","shortDescription":null}
А якщо увімкнути non_null, відповідь стане приблизно такою:
{"slug":"spring-boot"}
Тут важливо не те, що JSON став коротшим, а логіка контракту. Коли ви приховуєте null, клієнт має розуміти: поля може не бути взагалі. Коли ви залишаєте null, клієнт має розуміти: поле є завжди, але іноді порожнє. Це два різні стилі. У навчальному проєкті зазвичай зручніше non_null, щоб не тягнути зайву інформацію. У реальному продукті це рішення ухвалюють обережніше: іноді фронту простіше бачити фіксований набір полів.
Щоб приклад був самодостатнім, ось мінімальна навчальна версія CourseCard (у проєкті вона буде багатшою, але принцип той самий):
class CourseCard {
// Поля, які ми хочемо бачити (або не бачити) в JSON
private final String slug;
private final String shortDescription;
CourseCard(String slug, String shortDescription) {
// Якщо shortDescription = null, то поведінка залежатиме від default-property-inclusion
this.slug = slug;
this.shortDescription = shortDescription;
}
// Jackson за замовчуванням серіалізує властивості через гетери (або поля — залежно від налаштувань).
public String getSlug() {
return slug;
}
public String getShortDescription() {
return shortDescription;
}
}
5. indent-output і pretty JSON
У JSON є одна підступна особливість: машині байдуже, наскільки красиво він відформатований, а людині — ні. Ви відкриваєте відповідь у браузері або HTTP-клієнті й думаєте: «От би ще пробіли й переноси рядків, бо зараз це схоже на одну довгу макаронину». І це нормально, особливо поки ви навчаєтеся.
Для цього існує глобальний прапорець, який вмикає pretty-print (відступи):
spring:
jackson:
serialization:
# true: увімкнути pretty-print (відступи та переноси рядків у JSON)
indent-output: true
Тоді JSON стає багаторядковим і краще читається очима. Але є тонкий момент: це не змінює зміст даних, а лише форматування. У реальному сервісі pretty JSON зазвичай не вмикають постійно, тому що це зайві байти у відповіді й іноді зайве навантаження. У навчальному проєкті його можна вмикати на час, щоб простіше порівнювати відповіді та бачити структуру.
Якщо хочеться перевірити, як це впливає на JsonMapper, можна зробити мінідіагностику через ApplicationRunner:
import tools.jackson.databind.json.JsonMapper;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Map;
@Configuration
class JacksonIndentDemoConfiguration {
@Bean
ApplicationRunner indentDemo(JsonMapper jsonMapper) {
// Тут ми просто друкуємо серіалізацію в консоль, щоб побачити вплив indent-output.
return args -> {
System.out.println(jsonMapper.writeValueAsString(Map.of("title", "Spring Boot")));
// За indent-output=true вивід буде багаторядковим і з відступами
};
}
}
І тут корисно тримати межу в голові: indent-output — це радше режим зручності для розробника, а не важлива частина контракту. Контракт — це поля, типи значень і стабільні назви. Відступи — приємна косметика (а косметика, як відомо, не все лікує, але настрій піднімає).
6. Дати й час: рядки та timestamps
Щойно у вашому JSON зʼявляються дати, половина світу починає сперечатися про формати, а друга половина — страждати від наслідків. Добра новина: для Java Time API (LocalDate, Instant тощо) Spring Boot зазвичай уже дає розумну базову лінію — ISO-рядки. Це принаймні читабельно, передбачувано й нормально працює з більшістю клієнтів.
Проте важливо знати про глобальний прапорець, який вирішує: рядок чи timestamp. Він трапляється в реальних проєктах, і краще дізнатися про нього на лекції, а не о 2-й годині ночі, коли ваш фронт раптом отримав число замість дати.
spring:
jackson:
serialization:
# false: серіалізувати дати у вигляді ISO-рядків, а не числових timestamp
write-dates-as-timestamps: false
Щоб побачити ефект, можна серіалізувати Instant — просто як демонстрацію глобальної поведінки, не прив’язуючись до доменної моделі:
import tools.jackson.databind.json.JsonMapper;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.Instant;
import java.util.Map;
@Configuration
class JacksonDateDemoConfiguration {
@Bean
ApplicationRunner dateDemo(JsonMapper jsonMapper) {
return args -> {
// Демонстрація: Instant перетвориться на рядок або число — залежно від write-dates-as-timestamps
System.out.println(
jsonMapper.writeValueAsString(Map.of("now", Instant.parse("2026-03-19T12:00:00Z")))
); // {"now":"2026-03-19T12:00:00Z"}
};
}
}
Головна думка тут не в тому, щоб терміново все змінювати. Навпаки: зазвичай краще залишити ISO-рядки. Але корисно розуміти, що такі правила глобальні. Якщо ви увімкнете timestamps «заради одного місця», ви зміните поведінку всіх ендпоїнтів, де є дати, — і здивування буде масовим.
7. Межі spring.jackson.*
Є дуже спокусливий підхід: «О, у мене одне поле некрасиво серіалізується… Давайте підкрутимо spring.jackson.*, це ж зручно!» І ось ви випадково виносите один локальний випадок на рівень політики всього застосунку. Це приблизно як лагодити одну розетку, вимкнувши світло в усьому під’їзді. Формально проблему ви вирішили: у цій розетці точно немає напруги. Але сусідам чомусь не сподобалося.
Тут корисно тримати в голові критерій: глобальні властивості підходять для правил рівня «весь сервіс має так працювати». Наприклад, «не надсилати null», «не вмикати timestamps», «робити JSON читабельнішим». А якщо ви хочете змінити серіалізацію конкретного типу (наприклад, щоб Money відображався по-особливому) або одного поля (наприклад, тільки price), то глобальні властивості — занадто важкий молоток.
Поки що наша мета — навчитися спочатку шукати рішення в spring.jackson.*, але не перетворювати це на звичку «лікувати все налаштуванням». Для локальних випадків потрібні точкові інструменти: вони змінюють один тип, а не весь застосунок.
8. Типові помилки під час налаштування JSON
Помилка №1: створювати свій JsonMapper через new, бо «так зрозуміліше».
Це здається простим рішенням, але воно створює два набори правил серіалізації: один у Spring MVC (через автоконфігурацію Boot), другий у вашого саморобного mapper’а. У підсумку один і той самий об’єкт може перетворюватися на різний JSON залежно від місця, і відлагодження перетворюється на квест «знайди, який mapper зараз використовувався».
Помилка №2: використовувати JsonMapper у контролері для формування відповіді.
Іноді роблять return jsonMapper.writeValueAsString(obj); і повертають String. Це вбиває сенс @RestController: контролер має повертати дані, а не займатися серіалізацією. Плюс ви починаєте вручну керувати Content-Type, екрануванням та іншими дрібницями. Найчастіше це зайва робота і зайві помилки.
Помилка №3: вирішувати проблему одного типу глобальним прапорцем.
Наприклад, ви захотіли, щоб лише Money був рядком, і ввімкнули якесь глобальне правило, яке змінює поведінку половини моделей. Потім інший ендпоїнт раптово змінює форму, тести падають, а ви довго не пов’язуєте ці події. Глобальні правила — це політика застосунку, а не швидкий фікс.
Помилка №4: вмикати indent-output і потім забувати, що це косметика, а не контракт.
Pretty JSON зручний для навчання і ручної перевірки, але він не має ставати сенсом налаштування. Небезпека в тому, що ви починаєте судити API за красою форматування, а не за стабільністю структури. Клієнту важливі поля, значення та їхні типи, а пробіли — це бонус.
Помилка №5: змінювати default-property-inclusion без думки про контракт.
non_null справді зменшує шум, але змінює семантику: відсутність поля і null — це різні сигнали. Якщо у вас є клієнти, які очікують поле завжди, вони можуть почати ламатися. Навіть у навчальному проєкті корисно проговорювати це як інженерне рішення, а не як «зробімо красиво».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ