1. Форма JSON как контракт
Когда вы только стартуете, хочется радоваться любому JSON: «ура, оно не упало, значит всё хорошо». Но у backend-сервиса есть неприятная особенность: как только кто-то начинает потреблять ваш ответ (браузер, фронтенд, другой сервис, даже вы сами через неделю) — форма JSON становится контрактом. И контракт ломается не по настроению, а по факту.
Точечная настройка одного типа ещё не равна удачному публичному контракту. Клиенту нужен не просто “какой-то JSON”, а стабильная форма CourseCard, которую можно парсить без угадываний и сюрпризов.
Представьте, что ваш сервис — это кафе, а JSON — меню. Пока посетитель один (вы в Postman) — можно хоть “Суп №1”, хоть “Супчик вкусненький”. Но как только появляется второй посетитель (условный фронтенд), внезапно выясняется, что «Супчик вкусненький» — плохое имя для позиции: сегодня вы так назвали борщ, завтра — уху, а клиент уже привык, что это борщ. Вот ровно так же ломаются клиенты от «декоративного JSON».
В рамках нашего курса мы не уходим в полноценный REST API design, но мы обязаны сделать базовую вещь: выбрать JSON-форму CourseCard, которая одновременно:
- читаемая человеком,
- стабильно машиночитаемая,
- предсказуемая по всему приложению.
Чтобы это сделать, мы разберём три больных места для начинающего: enum, даты и деньги.
2. CourseCard в catalog-service: поля и форма
Сейчас у нас учебный read-only сервис, и мы можем позволить себе «простую жизнь»: возвращать наружу модель CourseCard без отдельного DTO-слоя. Это не универсальный рецепт на все времена, но для Boot-курса это отличный компромисс: меньше кода, меньше маппинга, больше понимания того, что делает Spring Boot и Jackson.
Базовая форма ответа
Чтобы не расплываться в абстракции, зафиксируем ориентир. Пример одной карточки курса в JSON (примерно так будет выглядеть GET /api/catalog/courses/{slug}):
{
"id": "spring-boot",
"slug": "spring-boot",
"title": "Spring Boot",
"shortDescription": "Boot fundamentals",
"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 как money-like объект.
Имена полей в 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-направление", потому что человеку приятнее. Но в backend обычно выигрывает другое: 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 April 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
После знакомства с type-specific кастомизацией легко появляется соблазн: «О! Давайте всегда сериализовать Money как строку "19900 GBP" — ведь красиво!». И иногда это правда удобно, например, для маленьких внутренних логов или очень узких сценариев, где деньги никогда не будут фильтроваться/сортироваться и нужны только как подпись.
Но для публичной формы CourseCard обычно лучше сохранить объект. И причина простая: сегодня вы хотите "19900 GBP", а завтра UI попросит отдельно подсветить валюту, посчитать скидку или перевести в другую валюту. Если цена уже пришла структурой, это делается легко. Если пришла строкой, клиент начинает писать «парсинг денег регулярками». А регулярки для денег — это как молоток для микросхем: что-то точно будет сломано.
В нашем baseline мы оставляем Money объектом, а @JacksonComponent для него рассматриваем только как осознанное исключение под отдельную задачу, но не как default snapshot 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
) {}
Это коротко, читаемо и отлично подходит для read-only модели. Если у вас 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 April 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 целым числом, чтобы не устраивать “фестиваль плавающей точки” в учебном сервисе.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ