JavaRush /Курси /Spring Boot /Форма JSON для CourseCard<...

Форма JSON для CourseCard

Spring Boot
Рівень 12 , Лекція 3
Відкрита

1. Форма JSON як контракт

Коли ви тільки починаєте, хочеться радіти будь-якому JSON: «ура, не впав — значить, усе добре». Але в бекенд-сервісу є неприємна особливість: щойно хтось починає споживати вашу відповідь — браузер, фронтенд, інший сервіс, навіть ви самі через тиждень — форма JSON стає контрактом. А контракт ламається не за настроєм, а за фактом.

Точкове налаштування одного типу ще не означає вдалий публічний контракт. Клієнту потрібен не просто «якийсь JSON», а стабільна форма CourseCard, яку можна парсити без вгадувань і сюрпризів.

Уявіть, що ваш сервіс — це кафе, а JSON — це меню. Поки відвідувач один — ви в Postman — можна хоч «Суп №1», хоч «Супчик смачненький». Але щойно зʼявляється другий відвідувач, умовний фронтенд, раптом з’ясовується, що «Супчик смачненький» — погана назва для позиції: сьогодні ви так назвали борщ, завтра — уху, а клієнт уже звик, що це борщ. Ось так і ламаються клієнти від декоративного JSON.

У межах нашого курсу ми не занурюємося в повноцінне проєктування REST API, але ми зобов’язані зробити базову річ: обрати JSON-форму CourseCard, яка одночасно:

  1. читабельна для людини,
  2. стабільно машинозчитувана,
  3. передбачувана в усьому застосунку.

Щоб цього досягти, розберімо три болючі місця для початківця: 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 рядок
"spring-boot"
Усе просто
int, long число
28
Без лапок
boolean логічне
true
Не "true"
enum рядок
"SPRING"
Зазвичай Enum.name()
LocalDate рядок
"2026-04-01"
ISO-8601, за належного налаштування
Money об’єкт
{ "amount": 19900, "currency": "GBP" }
Зберігаємо структуру

Ця таблиця — як легенда до карти. Вона не замінює розуміння, але допомагає не дивуватися кожній дрібниці.

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 Переваги Недоліки
Лише число
"price": 19900
просто незрозуміла валюта, не можна розширювати без ламання
Рядок-лейбл
"price": "19900 GBP"
приємно на око важко парсити, втрачається структура, UI-логіка лізе в API
Об’єкт
"price": { "amount": 19900, "currency": "GBP" }
машинозчитувано, придатно до розширення трохи більше символів

У навчальному 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 цілим числом, щоб не влаштовувати фестиваль плаваючої точки в навчальному сервісі.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ