1. Форма JSON як контракт
Коли ви тільки починаєте, хочеться радіти будь-якому JSON: «ура, не впав — значить, усе добре». Але в бекенд-сервісу є неприємна особливість: щойно хтось починає споживати вашу відповідь — браузер, фронтенд, інший сервіс, навіть ви самі через тиждень — форма JSON стає контрактом. А контракт ламається не за настроєм, а за фактом.
Точкове налаштування одного типу ще не означає вдалий публічний контракт. Клієнту потрібен не просто «якийсь JSON», а стабільна форма CourseCard, яку можна парсити без вгадувань і сюрпризів.
Уявіть, що ваш сервіс — це кафе, а JSON — це меню. Поки відвідувач один — ви в Postman — можна хоч «Суп №1», хоч «Супчик смачненький». Але щойно зʼявляється другий відвідувач, умовний фронтенд, раптом з’ясовується, що «Супчик смачненький» — погана назва для позиції: сьогодні ви так назвали борщ, завтра — уху, а клієнт уже звик, що це борщ. Ось так і ламаються клієнти від декоративного JSON.
У межах нашого курсу ми не занурюємося в повноцінне проєктування REST API, але ми зобов’язані зробити базову річ: обрати JSON-форму CourseCard, яка одночасно:
- читабельна для людини,
- стабільно машинозчитувана,
- передбачувана в усьому застосунку.
Щоб цього досягти, розберімо три болючі місця для початківця: enum, дати та гроші.
2. CourseCard в catalog-service: поля та форма
Зараз у нас навчальний сервіс лише для читання, і ми можемо дозволити собі просте життя: віддавати назовні модель CourseCard без окремого DTO-шару. Це не універсальний рецепт на всі випадки, але для Boot-курсу це чудовий компроміс: менше коду, менше мапінгу, більше розуміння того, що роблять Spring Boot і Jackson.
Базова форма відповіді
Щоб не розпливатися в абстракціях, зафіксуймо орієнтир. Ось як приблизно виглядатиме одна картка курсу в JSON (приблизно так віддаватиме GET /api/catalog/courses/{slug}):
{
"id": "spring-boot",
"slug": "spring-boot",
"title": "Spring Boot",
"shortDescription": "Основи Boot",
"track": "SPRING",
"level": "BASIC",
"durationDays": 28,
"featured": true,
"published": true,
"launchDate": "2026-04-01",
"price": {
"amount": 19900,
"currency": "GBP"
}
}
Тут спеціально все нудне. І це комплімент. Нудний JSON — зазвичай хороший JSON: його легко парсити, легко порівнювати, легко тестувати, легко пояснити.
Ми бачимо три ключові зони, які легко зіпсувати: track/level як enum, launchDate як дата, price як об’єкт грошового типу.
Імена полів у JSON та Java
Найчастіше запитання від новачка звучить приблизно так: «А чому JSON-ключі називаються shortDescription і durationDays, а не short_description і duration_days?». Відповідь проста: за замовчуванням Jackson бере імена Java-властивостей (геттерів/компонентів record) і перетворює їх на JSON-ключі.
Тобто, якщо в Java поле або геттер називається getLaunchDate(), у JSON ви побачите ключ launchDate. І доки ми не вводимо спеціальні naming strategies, @JsonProperty та інші тонкі налаштування, це найпередбачуваніша й найзрозуміліша лінія поведінки.
Тут важливо не переплутати: у YAML-конфігурації ми пізніше часто використовуватимемо kebab-case (launch-date, duration-days) через relaxed binding і читабельність конфігів. Але JSON-відповіді вебшару — це окрема історія, і в поточному baseline ми тримаємо camelCase, тому що він природно випливає з Java-коду.
Мінітаблиця: Java-типи в JSON
Невеличка «карта місцевості», щоб ви щоразу не вгадували:
| Java-тип у моделі | Як виглядає в JSON | Приклад | Коментар |
|---|---|---|---|
| String | рядок | |
Усе просто |
| int, long | число | |
Без лапок |
| boolean | логічне | |
Не "true" |
| enum | рядок | |
Зазвичай Enum.name() |
| LocalDate | рядок | |
ISO-8601, за належного налаштування |
| Money | об’єкт | |
Зберігаємо структуру |
Ця таблиця — як легенда до карти. Вона не замінює розуміння, але допомагає не дивуватися кожній дрібниці.
3. enum в JSON: стабільні значення
З enum у початківців регулярно трапляються дві крайності. Перша: «ну й гаразд, нехай якось серіалізується». Друга: «хочу зробити красиво: “Spring-напрямок”, “рівень: базовий”, і бажано українською, і щоб наприкінці був смайлик». Друга крайність зазвичай закінчується тим, що хтось плаче, і це не обов’язково ви.
Простий baseline: enum як рядок
Якщо у вас є:
public enum CourseTrack {
// Важливо: значення enum стають частиною JSON-контракту (зазвичай через Enum.name()).
// Тому це мають бути стабільні технічні коди, а не «красиві підписи».
SPRING,
JAVA_BACKEND,
DATA,
INFRA,
ADVANCED
}
то Jackson за замовчуванням зробить JSON на кшталт "SPRING".
Те саме для рівня:
public enum CourseLevel {
// Ці значення також «виїжджають назовні» в JSON, тому перейменування = зміна контракту.
BASIC,
JUNIOR_PLUS,
MIDDLE
}
Це дуже нудний і водночас дуже стабільний підхід. Він добре працює і для навчального сервісу, і для більшості реальних внутрішніх API. Плюс такий JSON легко використовувати і у фільтрах, і в UI, і в тестах.
Головний підводний камінь: перейменування констант
Тут важливо сказати чесно і прямо: enum у JSON серіалізується в рядок, який зазвичай дорівнює імені константи. Отже, якщо ви перейменували JUNIOR_PLUS на JUNIORPLUS «тому що так красивіше» — ви змінили JSON. А якщо JSON змінився, клієнту боляче.
Це не означає «ніколи не перейменовуйте enum». Це означає «ставтеся до цього як до зміни контракту». Навіть у навчальному проєкті корисно сформувати правильну звичку: enum — не просто «список варіантів у Java», це ще й значення, які побачить зовнішній світ.
Людиночитабельність enum і JSON
Дуже спокусливо зробити замість "SPRING" щось на кшталт "Spring-напрямок", тому що людині приємніше. Але в бекенді зазвичай перемагає інше: JSON — для даних, а не для оформлення.
Читабельність для людини забезпечує UI. А UI майже завжди хоче:
- мати можливість локалізувати підписи (українська/англійська),
- мати можливість змінювати текст без зміни контракту,
- мати можливість сортувати й фільтрувати за стабільним значенням.
Якщо ви запхаєте «красивий підпис» прямо в JSON, ви фактично змішаєте дані та інтерфейс. На маленькому проєкті це майже непомітно, а на великому робить зміни болючими.
4. Дати: єдиний формат LocalDate
Дати — це класична тема, де програмісти діляться на дві категорії. Перша: «ну це ж просто рядок». Друга: «давайте влаштуємо битву за часові пояси до останнього студента». Ми оберемо третій шлях: розумний baseline без релігійних війн.
launchDate у домені та вибір LocalDate
У доменній моделі каталогу launchDate — це дата запуску курсу, без часу доби. Нам не важливо, чи стартує він «о 14:37 за Токіо», чи «о 09:00 за Нью-Йорком». Це не розклад вебінара, а дата релізу або старту.
Саме тому в Java це дуже добре виражається типом LocalDate. Він буквально означає «дата без часу й без часового поясу». Це зменшує кількість помилок: ви не зможете випадково «додати дві години» і раптово отримати вчорашній день через зону.
LocalDate у JSON
В адекватному baseline LocalDate серіалізується в рядок формату ISO-8601:
"launchDate": "2026-04-01"
Чому це круто:
- формат короткий і стандартний;
- він сортується як рядок правильно (лексикографічно "2026-04-02" більше за "2026-04-01" — магія, яка насправді просто вдалий формат);
- його легко розпарсити будь-якою мовою.
Чому це ще й практично: ви не змушуєте клієнта вгадувати, це Unix timestamp чи «мілісекунди з епохи», і не провокуєте помилки на рівному місці.
Антиприклад: «декоративна дата» як рядок
Ось такий підхід здається дружнім:
"launchDate": "1 квітня 2026"
Але це JSON, який одразу створює проблеми. Клієнту потрібно вгадати мову, формат, регістр місяця, а потім ще й зрозуміти, що робити, коли ви раптово захочете "April 1, 2026" (тому що «так заведено в США») або "01.04.2026" (тому що «так заведено в нас»).
Коли ви віддаєте дані, ви не хочете, щоб клієнт парсив «смачні рядки». Ви хочете, щоб клієнт парсив структуровані значення. Тому ISO-рядок "2026-04-01" — нудно, передбачувано, правильно.
Чому поки не чіпаємо час і часові пояси
Можна сказати так: щойно ви додаєте час і часовий пояс, ви додаєте в проєкт ще один маленький курс — «Як не зламати час». У цьому курсі (Spring Boot як платформа) ми тримаємо фокус і обираємо найпростіші стійкі сутності.
Якщо колись у домені з’явиться «час старту вебінара», тоді так, можна буде обговорювати Instant, OffsetDateTime, нормальні часові пояси та інші нюанси. Але CourseCard зараз — це картка каталогу, а не календар космічних запусків.
5. Money: форма та структура
Money — це той тип, на якому люди люблять «зрізати кути», а потім виявляють, що ці кути були несучими. Можна зробити ціну рядком, можна числом, можна об’єктом… і раптом з’ясовується, що різні варіанти по-різному впливають на те, наскільки JSON лишається машинозчитуваним і придатним до розширення.
Три популярні форми Money у JSON
Зазвичай трапляються три стратегії:
| Варіант | Приклад JSON | Переваги | Недоліки |
|---|---|---|---|
| Лише число | |
просто | незрозуміла валюта, не можна розширювати без ламання |
| Рядок-лейбл | |
приємно на око | важко парсити, втрачається структура, UI-логіка лізе в API |
| Об’єкт | |
машинозчитувано, придатно до розширення | трохи більше символів |
У навчальному catalog-service ми обираємо об’єкт. Тому що наша мета — не «найкоротший JSON у світі», а зрозумілий і стабільний baseline.
Рекомендована модель Money у Java
Якщо ви використовуєте звичайний клас, він може виглядати так (у пакеті catalog.domain):
public class Money {
// Сума в мінімальних «логічних» одиницях, які ви обрали в проєкті (у навчальному — просто ціле).
private final int amount;
// Код валюти, який йтиме в JSON як рядок (наприклад, "GBP").
private final String currency;
public Money(int amount, String currency) {
this.amount = amount;
this.currency = currency;
}
public int getAmount() { return amount; }
public String getCurrency() { return currency; }
}
Jackson побачить геттери й зробить:
"price": { "amount": 19900, "currency": "GBP" }
Якщо у вас Money оформлений як record, Jackson теж упорається. Важливо не те, class це чи record, а те, що це структуроване значення, а не «текст для краси».
Нюанс: що таке amount
У реальному житті гроші — складна тема, і на рівні production заведено зберігати суми в мінімальних одиницях (центи, копійки) або використовувати BigDecimal + фіксовані правила округлення. Але наш курс — не про фінтех, і ми не будуємо платіжну систему.
Тому в навчальному проєкті ми робимо максимально просту річ: amount — ціле число, а currency — рядковий код. Головне, що ми виграємо, — це зрозуміла структура, а не «ідеальна грошова математика».
Коли доречний рядок через @JacksonComponent
Після знайомства з кастомізацією для конкретного типу легко з’являється спокуса: «О! Давайте завжди серіалізувати Money як рядок "19900 GBP" — адже красиво!». Іноді це справді зручно, наприклад, для невеликих внутрішніх логів або дуже вузьких сценаріїв, де гроші ніколи не будуть фільтруватися чи сортуватися і потрібні лише як підпис.
Але для публічної форми CourseCard зазвичай краще зберегти об’єкт. Причина проста: сьогодні ви хочете "19900 GBP", а завтра UI попросить окремо підсвітити валюту, порахувати знижку або перевести в іншу валюту. Якщо ціна вже прийшла структурою, це робиться легко. Якщо прийшла рядком, клієнт починає писати «парсинг грошей регулярками». А регулярки для грошей — це як молоток для мікросхем: щось точно зламається.
У нашому baseline ми залишаємо Money об’єктом, а @JacksonComponent для нього розглядаємо лише як свідомий виняток під окрему задачу, але не як типовий вигляд CourseCard.
6. Приклад форми JSON для CourseCard
Зараз зберемо все в одну зрозумілу картину. Нам важливо побачити, що підсумкова форма JSON — це не «налаштування десь у хмарах», а прямий наслідок Java-моделі: enum перетворюються на рядки, LocalDate перетворюється на ISO-рядок, Money перетворюється на вкладений об’єкт.
Мінімальна модель CourseCard (record)
Якщо вам зручно використовувати records для незмінних моделей, CourseCard може виглядати так:
import java.time.LocalDate;
public record CourseCard(
// Технічний ідентифікатор (часто збігається зі slug, але не зобов’язаний).
String id,
// Те, що зазвичай використовується в URL.
String slug,
// Читабельний заголовок для людини.
String title,
// Enum виїжджає в JSON як стабільний рядок (Enum.name()).
CourseTrack track,
// Enum виїжджає в JSON як стабільний рядок (Enum.name()).
CourseLevel level,
// Дата без часу й без таймзони: в JSON очікуємо ISO-рядок виду YYYY-MM-DD.
LocalDate launchDate,
// Гроші як структурований об’єкт: amount + currency.
Money price
) {}
Це коротко, читабельно й чудово підходить для моделі лише для читання. Якщо у вас CourseCard зроблений звичайним класом з геттерами — JSON виглядатиме майже так само, тож суть не змінюється.
Швидкий «знімок форми» через JsonMapper
Іноді корисно перевірити форму JSON без браузера й без HTTP — просто серіалізувати об’єкт і побачити рядок. Наприклад, усередині невеликого runner’а:
import tools.jackson.databind.json.JsonMapper;
import java.time.LocalDate;
public class JsonShapeDemo {
public static void main(String[] args) throws Exception {
// Створюємо mapper вручну, щоб побачити форму JSON без Spring MVC і без HTTP.
// У самому Boot-застосунку mapper уже приходить з auto-configuration.
JsonMapper mapper = new JsonMapper();
// Збираємо приклад доменного об’єкта: це дані, які Jackson буде перетворювати на JSON-структуру.
CourseCard card = new CourseCard(
"spring-boot", "spring-boot", "Spring Boot",
CourseTrack.SPRING, CourseLevel.BASIC,
LocalDate.of(2026, 4, 1),
new Money(19900, "GBP")
);
// Серіалізація: жодних конкатенацій рядків — лише дані -> JSON.
System.out.println(mapper.writeValueAsString(card));
// {"id":"spring-boot","slug":"spring-boot","title":"Spring Boot","track":"SPRING","level":"BASIC","launchDate":"2026-04-01","price":{"amount":19900,"currency":"GBP"}}
}
}
Тут важлива не сама main(), а спостереження: ви буквально бачите, як Java-структура стає JSON-структурою. Жодних «ручних рядків», жодних конкатенацій. Лише дані → JSON.
Так, у реальному Spring Boot-застосунку JsonMapper уже створюється Boot’ом і приходить як bean, і в нього будуть ваші глобальні налаштування spring.jackson.*. Але як демонстрація форми це завдання дуже наочне.
Форма на endpoint’ах: один об’єкт і список об’єктів
Коли controller повертає один CourseCard, клієнт отримує JSON-об’єкт. Коли controller повертає List<CourseCard>, клієнт отримує JSON-масив об’єктів.
Смислова форма картки при цьому не змінюється. Це важливе правило узгодженості: одна й та сама сутність має однаково виглядати і в списку, і в детальному перегляді.
Приклад масиву (спрощено):
[
{
"slug": "spring-boot",
"track": "SPRING",
"launchDate": "2026-04-01",
"price": { "amount": 19900, "currency": "GBP" }
},
{
"slug": "spring-data-jpa",
"track": "DATA",
"launchDate": "2026-05-15",
"price": { "amount": 24900, "currency": "GBP" }
}
]
Бачите, наскільки зручно читати й парсити такий JSON: типи не вгадуються, вони очевидні.
7. Типові помилки під час вибору форми JSON для CourseCard
Помилка №1: перетворювати структуру на «красиві рядки» занадто рано.
Дуже легко скотитися до формату "price": "19 900 GBP" і "launchDate": "1 квітня 2026", тому що так приємно оку. Але ви платите за це втратою структури: клієнту потрібно парсити рядки назад у числа й дати, а це завжди джерело багів. JSON для даних має бути нудним і передбачуваним, а «красивість» — завдання інтерфейсу.
Помилка №2: різні формати дат на різних endpoint’ах.
Якщо десь дата "2026-04-01", а десь раптово 1711929600 (секунди епохи) або "01.04.2026", клієнт починає жити у світі «вгадай формат». Навіть один такий викид робить увесь сервіс менш надійним. Оберіть один формат (у нашому baseline — ISO-рядок) і дотримуйтеся його.
Помилка №3: «покращувати» enum-значення так, що вони стають нестабільними.
Сьогодні вам здається, що "SPRING" — сухо, і хочеться "Spring Framework Track". Завтра маркетинг попросить "Spring напрямок". Післязавтра — "Spring 💚". І ось уже API змінюється від настрою. enum у JSON має бути стабільним технічним значенням, а підписи мають жити окремо.
Помилка №4: змішувати в голові YAML-конфіг і JSON-відповіді.
У нашому проєкті YAML і справді частіше буде в kebab-case, а JSON — у camelCase. Новачки іноді намагаються «зробити однаково всюди» й починають змінювати JSON naming strategy без реальної потреби. У результаті з’являються зайві налаштування, зайва складність і зайві сюрпризи. На базовому рівні краще прийняти: конфіг і web-відповіді — це різні шари, у них різні зручні формати.
Помилка №5: використовувати double для грошей, а потім дивуватися дивним значенням.
Якщо ви колись побачите ціну 19.8999999997 там, де очікували 19.9, знайте: це не «Spring знову чаклує», це звичайна математика double. У нашому проєкті ми тримаємо amount цілим числом, щоб не влаштовувати фестиваль плаваючої точки в навчальному сервісі.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ