Когда API возвращает данные, клиент ожидает предсказуемую структуру: понятные имена полей, читаемые даты, чёткие правила про отсутствующие значения. Без договорённостей каждая команда изобретает свой формат — и интеграция превращается в квест угадать, что значит null в этом поле.
В этой статье разберём конкретные правила: как называть поля, как отдавать даты, когда возвращать 201, а когда 204, и почему null в ответе — это проблема.
Ниже — один и тот же заказ в трёх форматах ответа: видно, что меняется в теле и в коде клиента, когда из ответа убирают обёртку и null-поля.
Состояние заказа во всех трёх тактах одно и то же — меняется только формат ответа. Убрали обёртку success/data/error — пропал лишний уровень, клиент читает r.orderId вместо r.data.orderId. Добавили non_null — из восьми полей тела остались три, и ни одного со значением null. Пустой массив items остаётся: это не «нет данных», а «есть, элементов нет».
Имена полей: camelCase, а не snake_case
В одной интеграции половина полей created_at, половина createdAt: одно писали руками, другое сгенерировал Jackson, и клиент держит два словаря. В вебе сложились два лагеря, created_at с order_id и createdAt с orderId, и для JSON-API на Java выбирают camelCase: JavaScript, главный потребитель REST API, использует его по умолчанию, а Jackson, стандартная JSON-библиотека в Spring, пишет имена полей как в Java, то есть тоже camelCase, и ничего переопределять не нужно.
Хороший пример JSON-ответа:
{
"orderId": "550e8400-e29b-41d4-a716-446655440000",
"totalAmount": 1500.00,
"status": "IN_PROGRESS",
"createdAt": "2026-05-26T10:30:00Z",
"deliveryAddress": {
"streetName": "Ленина",
"zipCode": "123456"
},
"items": [
{ "itemId": "abc123", "productName": "Клавиатура", "quantity": 2 }
]
}
Несколько правил по именованию:
- Идентификаторы — с суффиксом
Id:orderId,customerId,parentCategoryId. Простоidнеочевидно — идентификатор чего? - Даты — строка в формате ISO 8601:
2026-05-26для даты,2026-05-26T10:30:00Zдля момента времени (сZдля UTC). Пишите с буквойTпосередине. Формально RFC 3339 (это профиль ISO 8601 для интернета) разрешает поставить вместоTпробел, если стороны договорились, — но договориться придётся со всеми, а часть готовых парсеров такую строку просто не примет. Проще не отклоняться. - Коллекции — во множественном числе:
items,tags,errors. - Enum-значения —
UPPER_SNAKE_CASE:IN_PROGRESS,CREDIT_CARD,OUT_OF_STOCK. Клиент сразу видит, что это перечисление.
Длинные числовые идентификаторы отдают строкой
В примере выше идентификаторы — UUID, то есть строки, и вопросов не возникает. Но если в базе bigint, идентификатор приезжает в JSON числом, и тут есть неприятный сюрприз.
В JavaScript обычное число хранится так, что без потерь помещаются целые примерно до 9 007 199 254 740 992 (это 2 в 53-й степени). Всё, что больше, округляется. Отдали 9007199254740993 — браузер прочитал 9007199254740992, и клиент пошёл открывать чужой заказ. Ошибки нет, исключения нет, просто последняя цифра другая.
База данных до таких чисел доходит редко, но доходит: идентификаторы из внешних систем, снежинки-генераторы, номера сообщений. Правило простое — если идентификатор long и может вырасти за 2^53, отдавайте его строкой:
{ "messageId": "9007199254740993" }
В Jackson это делается аннотацией @JsonFormat(shape = JsonFormat.Shape.STRING) на поле или аннотацией @JsonSerialize(using = ToStringSerializer.class). UUID эта беда не касается — он и так строка.
Boolean-поля
Нет жёсткого требования писать isActive или active — оба варианта встречаются. Важно одно: единообразие в проекте. Если выбрали active/enabled — так везде; если isActive/isEnabled — тоже везде.
{
"active": true,
"hasDiscount": false,
"canCancel": true
}
Даты и время: ISO 8601
Всегда используйте ISO 8601. Это международный стандарт, который понимают все библиотеки во всех языках.
- Дата:
"2026-05-26" - Дата и время (UTC):
"2026-05-26T10:30:00Z" - Дата и время с офсетом:
"2026-05-26T13:30:00+03:00"
Частая ошибка — передавать Unix timestamp как число (1716720600). Это машиночитаемо, но неудобно при отладке и не очевидно клиенту. Строка ISO 8601 читается человеком и при этом одинаково хорошо парсится.
Деньги: 1500.00 в JSON — это не 1500 рублей 00 копеек
В примере выше стоит "totalAmount": 1500.00, и выглядит это безобидно. На деле в JSON нет отдельного типа для денег — есть просто «число», и каждая сторона трактует его как хочет.
Что произойдёт с этим числом дальше:
- Браузер прочитает его как обычное дробное число и при обратной записи отдаст
1500— копейки, которые вы аккуратно вывели, исчезнут из вида. А если сложить два таких значения, вылезет знаменитое0.1 + 0.2 = 0.30000000000000004: в двоичной дроби десятые и сотые не записываются точно. - Java-клиент, который читает ответ без готового класса — в
MapилиJsonNode, — по умолчанию положит значение вdouble, а не вBigDecimal. Дальше то же самое: сумма чека поедет на копейку.
Три рабочих варианта, любой годится, главное — выбрать один и написать его в документации:
{ "totalAmount": "1500.00" } строкой
{ "totalAmountMinor": 150000, "currency": "RUB" } в копейках, целым
{ "totalAmount": 1500.00 } числом — только если обе стороны читают его в BigDecimal
В своём коде деньги держат в BigDecimal, никогда в double. Чтобы Jackson отдавал их строкой, ставят на поле @JsonFormat(shape = JsonFormat.Shape.STRING). А если приходится читать чужой JSON без схемы, включают настройку, которая кладёт дробные числа в BigDecimal, а не в double:
spring:
jackson:
deserialization:
use-big-decimal-for-floats: true
Формат ответа зависит от операции
Разные операции — разные коды ответа и разная структура тела.
Создание (POST) — 201 + Location + тело
Когда ресурс создан, сервер возвращает статус 201 Created и заголовок Location со ссылкой на созданный ресурс. В теле — сам созданный объект:
HTTP/1.1 201 Created
Location: /api/v1/orders/550e8400-e29b-41d4-a716-446655440000
{
"orderId": "550e8400-e29b-41d4-a716-446655440000",
"status": "CREATED",
"totalAmount": 0,
"createdAt": "2026-05-26T10:30:00Z"
}
Зачем Location? Клиент сразу знает URL нового ресурса, не нужно дополнительно угадывать или конструировать.
Обновление (PUT/PATCH) — 200 + обновлённый ресурс
После обновления возвращаем актуальное состояние объекта:
HTTP/1.1 200 OK
{
"orderId": "550e8400-...",
"status": "CONFIRMED",
"totalAmount": 1500.00,
"updatedAt": "2026-05-26T11:00:00Z"
}
Клиент сразу видит, что изменилось — без дополнительного GET-запроса.
Удаление (DELETE) — 204 No Content
Удаление ничего не возвращает. Статус 204 No Content, тело пустое:
HTTP/1.1 204 No Content
Не нужно отдавать { "success": true } — статус 204 уже говорит об успехе.
Действие над ресурсом (action) — 200 + результат
Если endpoint — это действие (подтвердить заказ, заблокировать пользователя), возвращаем обновлённый ресурс:
POST /api/v1/orders/550e8400-.../confirm
HTTP/1.1 200 OK
{
"orderId": "550e8400-...",
"status": "CONFIRMED",
"confirmedAt": "2026-05-26T11:00:00Z"
}
Единичный ресурс — плоский объект
Никаких обёрток. Просто объект:
{
"orderId": "550e8400-...",
"status": "CONFIRMED",
"totalAmount": 1500.00,
"createdAt": "2026-05-26T10:30:00Z"
}
Вложенные объекты и массивы внутри ресурса — нормально. Но не заворачивайте ресурс в { "data": ..., "success": true } — это антипаттерн, о нём ниже.
Коллекция — content + метаданные пагинации
Когда возвращаете список с пагинацией, структура такая:
{
"content": [
{ "orderId": "..." },
{ "orderId": "..." }
],
"page": 1,
"size": 20,
"totalElements": 243,
"totalPages": 13
}
Поле content — это сами данные, рядом — метаданные пагинации. Это не «обёртка» в плохом смысле, это структура страницы.
Как клиент должен читать ответ, чтобы пережить новое поле
Правило «добавление поля — не ломающее изменение» верно только если клиент игнорирует незнакомые поля. А это настройка, и по умолчанию она разная.
В Spring Boot проверка на незнакомые поля выключена (FAIL_ON_UNKNOWN_PROPERTIES=false) — то есть ваш сервис, читая чужой ответ, лишнее поле переживёт. В голом Jackson без Spring эта проверка включена, и неизвестное поле роняет разбор с ошибкой. Отсюда классика: библиотека-клиент, собранная своими руками, падает после того, как сервер добавил поле, — хотя контракт не нарушен.
ObjectMapper mapper = JsonMapper.builder()
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.build();
Это первое, что настраивают в клиенте чужого API, и то, о чём предупреждают в описании своего: «добавление полей — штатное изменение, клиент обязан игнорировать незнакомые». Обратная сторона — опечатка в имени поля перестаёт быть заметной: поле молча не заполнится, и поймает это только тест.
Неизвестное значение перечисления
Второй способ уронить клиента добавлением — новое значение в перечислении. Jackson по умолчанию падает на неизвестном значении, и добавление статуса PARTIALLY_REFUNDED ломает всех, кто читает статус в свой enum.
На входе (сервер читает запрос) это правильное поведение: неизвестное значение — ошибка клиента, и ответ должен быть 400 с внятным сообщением о том, какие значения допустимы. Автоматическое сообщение Jackson для этого не годится: оно длинное, содержит имена Java-классов и не помогает. Поэтому перечисления разбирают явно и отвечают своим сообщением:
public record CreateOrderRequest(@NotNull String status) {
public OrderStatus toStatus() {
return OrderStatus.parse(status)
.orElseThrow(() -> new BadEnumValueException("status", status, OrderStatus.names()));
}
}
На выходе (клиент читает ответ) правильное поведение обратное: неизвестное значение не должно ронять разбор. Настройка READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE вместе с пометкой значения по умолчанию (@JsonEnumDefaultValue UNKNOWN) превращает новое значение в UNKNOWN, и клиент решает, что с ним делать, — обычно «показать как есть, ничего не решать по нему».
То же для чисел: 1500.00 в поле, которое клиент читает как целое, и 1e3 вместо 1000 — законный JSON. Поэтому деньги всегда строкой (о чём выше), а числовые поля — с явным типом в описании контракта.
Согласование формата: Accept, 406, 415
Три кода, которые путают, хотя различие механическое.
415 Unsupported Media Type — сервер не понимает то, что вы прислали. Отправили POST с Content-Type: text/plain, а ручка принимает только JSON — это 415. Spring отвечает так сам, если у метода указано consumes.
406 Not Acceptable — сервер не умеет отдать то, что вы просите. Прислали Accept: application/xml, а сервис умеет только JSON — это 406. На практике встречается редко, потому что большинство клиентов присылают Accept: */*.
400 Bad Request — формат тот, а содержимое неверное: невалидный JSON, отсутствующее обязательное поле, неверный тип значения. Путают именно с 415: «прислал сломанный JSON» — это 400, «прислал не JSON вовсе» — 415.
@PostMapping(path = "/orders", consumes = "application/json", produces = "application/json")
public ResponseEntity<OrderView> create(@RequestBody @Valid CreateOrderRequest request) { … }
Отдельно про отсутствующий Content-Type: сервер вправе ответить 415, и это правильно — угадывать формат тела опасно. Клиент обязан его передавать всегда, когда есть тело.
Про сжатие тут же: клиент говорит, что понимает (Accept-Encoding: gzip), сервер отвечает, что применил (Content-Encoding: gzip). Включают это на прокси; в ответе обязателен Vary: Accept-Encoding, иначе кэш отдаст сжатое непонимающему клиенту.
Список без пагинации: массив или объект
Соблазн отдать голый массив ([{…},{…}]) велик — он короче. Проблема появляется позже: добавить к такому ответу метаданные нельзя, не сломав клиентов. Понадобилось общее число, признак «есть ещё», время формирования, отметка устаревания данных — и корень ответа приходится менять с массива на объект, а это ломающее изменение для всех.
Поэтому правило простое: в корне ответа всегда объект, даже если внутри одно поле.
{ "items": [ { "id": "ord-1" }, { "id": "ord-2" } ] }
Тогда добавление total, nextCursor или warnings — обычное расширение. Исключение делают для совсем внутренних ручек, где клиент один и он ваш, — но и там это экономит пять символов и стоит будущей миграции.
Второй довод: голый массив в корне исторически был вектором атаки в браузерах (перехват ответа через подмену конструктора массива). Современные браузеры это закрыли, но привычка осталась не зря.
Размер ответа: сколько полей отдавать
Ответ, который тянет весь агрегат «на всякий случай», дорог трижды: база читает больше, сервис собирает больше, сеть передаёт больше. И дороже всего — в списке, где это умножается на число элементов.
Три приёма, в порядке предпочтения.
Разные представления для разных мест. Список отдаёт короткую карточку (идентификатор, название, цена, статус), карточка — полное представление. Это самый честный способ: у каждой ручки свой контракт, который видно в описании.
Выбор полей клиентом (?fields=id,title,price). Гибко и опасно: контракт становится динамическим, кэшировать труднее (ответ зависит от набора полей), а проверять права на каждое поле приходится отдельно. Берут в публичных API с разнородными клиентами; для двух-трёх экранов дешевле два представления.
Ссылки вместо вложения. Вместо того чтобы вкладывать полный объект покупателя в каждый заказ, отдают его идентификатор (и, если нужно, ссылку). Клиент запросит подробности, если они ему нужны, — и получит их один раз, а не в каждом элементе списка.
И про вложенность: практический предел — два уровня. order.customer.name читается; order.customer.address.city.region.country означает, что в ответ попал весь граф, и любое изменение модели ломает контракт. Глубже двух уровней — признак, что пора либо плоско выложить нужные поля, либо отдать ссылку.
Когда тело ответа не нужно
201 с телом созданного объекта — хорошее умолчание, но не единственный вариант.
202 Accepted — операция принята, но ещё не выполнена: создание идёт в фоне, письмо отправится позже, отчёт считается минуту. Тела созданного объекта ещё не существует, поэтому в ответе отдают то, по чему можно следить: идентификатор задачи и адрес для проверки состояния. Подробно — в статье про пакетные и асинхронные операции.
204 No Content — сделано, отдавать нечего: удаление, снятие метки, подтверждение. Тела нет вовсе (и его не должно быть — некоторые клиенты на тело при 204 реагируют ошибкой).
201 без тела — законно, если создание дорогое, а клиенту достаточно адреса: тогда в ответе только Location, а объект клиент получит запросом, если он ему нужен.
Про сам Location: по стандарту он может быть относительным (/orders/ord-42), и это удобнее за прокси — абсолютный адрес приходится собирать из заголовков переданной схемы и узла, и именно там появляются ссылки на http:// в HTTPS-сервисе. Если отдаёте абсолютный — собирайте его из тех же заголовков, которым доверяет приложение.
null в ответе — почему это проблема
В ответе "discount": null, и клиент не знает, что это значит: скидки у заказа нет, скидку ещё не посчитали или её сняли. Три разных состояния, одно значение, и код клиента вынужден угадывать. Каждое значение null — это неопределённость, а не экономия.
Правило простое: если поля нет — его не должно быть в JSON совсем, а не "discount": null.
Плохо:
{
"orderId": "...",
"discount": null,
"comment": null
}
Хорошо:
{
"orderId": "...",
"status": "CONFIRMED"
}
Ещё плюсы: меньше трафик, клиентский код проще (if (data.discount) вместо if (data.discount !== null && data.discount !== undefined)).
В Spring это настраивается одной строкой — говорим Jackson не включать null-поля:
spring:
jackson:
default-property-inclusion: non_null
Именно настройкой, а не своим бином ObjectMapper. Собственный бин заменяет тот, что собирает Spring Boot, и вместе с ним пропадают все умолчания автонастройки — в первую очередь запись дат в формате ISO 8601, о котором говорилось выше.
Сломается это одним из двух способов, и оба неприятны по-своему.
Если новый ObjectMapper создан голым, он про java.time вообще не знает и на первом же LocalDateTime бросит исключение прямо при формировании ответа: «Java 8 date/time type not supported by default». Клиент получит 500 на эндпоинте, который вчера работал.
Если же при создании подтянули модули (findAndRegisterModules()), исключения не будет — будет хуже. Вместе с модулями включается умолчание «писать даты числами», и вместо привычных строк наружу поедет вот такое:
{
"createdAt": [2026, 5, 26, 10, 30],
"paidAt": 1779791400.000000000
}
Момент времени превратился в число секунд, а дата со временем — в массив из пяти чисел. Формат ответа изменился, хотя код контроллера никто не трогал.
Если правило нужно задать кодом, добавляйте настройщик, а не новый объект:
@Bean
Jackson2ObjectMapperBuilderCustomizer nonNull() {
return builder -> builder.serializationInclusion(JsonInclude.Include.NON_NULL);
}
Та же логика про пустые строки: "" — это не «нет данных», это «есть, но пусто». Если данных нет — поля нет в JSON.
null в теле PATCH-запроса — другая история
В PATCH-запросах null имеет особый смысл согласно стандарту JSON Merge Patch (RFC 7396): это команда удалить поле.
PATCH /api/v1/orders/550e8400-...
Content-Type: application/merge-patch+json
{ "comment": null }
Это говорит: «убери поле comment из ресурса». Это семантика запроса, не нарушение правила про null в ответах.
Цена настройки non_null
Здесь же вылезает то, за что за default-property-inclusion: non_null приходится платить. Настройка глобальная: она действует на всё, что сериализует этот ObjectMapper, — и на ответы, и на тела запросов, если тем же объектом пользуется ваш клиент к соседнему сервису. А значит, отправить {"comment": null} через него уже не получится: поле со значением null просто не попадёт в JSON, и вместо «сотри комментарий» уйдёт пустое {}.
Вторая половина той же проблемы — на приёмной стороне. Обычный класс запроса не отличает «поле не прислали» от «поле прислали со значением null»: в обоих случаях в объекте лежит null. Сервер не может понять, надо ли трогать комментарий вообще.
Лечится это не отменой non_null, а отдельным типом для PATCH-тела, у которого есть три состояния вместо двух: значение, явный null, отсутствие. В Java для этого берут JsonNullable (он приезжает с jackson-databind-nullable) или заводят обёртку сами:
record UpdateOrderRequest(JsonNullable<String> comment) {}
if (request.comment().isPresent()) { // поле прислали
order.setComment(request.comment().get()); // внутри может быть и null — это «стереть»
}
И отдельный ObjectMapper для исходящих запросов, если ваш клиент к чужому API должен уметь слать null осознанно.
Envelope — антипаттерн
Иногда видят такой формат ответа:
{
"success": true,
"data": {
"orderId": "...",
"status": "CREATED"
},
"error": null
}
Это называется envelope («обёртка»). Кажется удобным — всегда одинаковая структура. Но на практике это лишний слой без пользы:
- HTTP-статус (200, 404, 500) уже сообщает, успех или ошибка — дублировать его в
"success": trueнезачем. - Для ошибок есть отдельный стандарт (RFC 9457), который лучше описывает проблему.
- Клиент пишет
response.data.orderIdвместоresponse.orderId— лишний уровень.
Правильно: единичный ресурс возвращается плоским объектом, коллекция — через { "content": [...] } с пагинацией.
Пустые коллекции — [] а не null
Если коллекция пуста — возвращаем пустой массив, не null и не отсутствие поля:
{ "items": [] }
[] означает «коллекция есть, элементов нет». null или отсутствие поля — двусмысленно: то ли коллекция пуста, то ли её нет, то ли не загружена.
Глубже: согласование содержимого: Accept, 406, 415 и сжатиерасширенное
Content-Type: application/json стоит в каждом примере, и ни разу не сказано, что делает сервер, когда клиент просит или присылает другое.
Клиент объявляет, что умеет читать, заголовком Accept, а что прислал, заголовком Content-Type. Два кода на два несовпадения. Клиент прислал Content-Type: text/plain или XML на ручку, которая понимает только JSON: 415 Unsupported Media Type. Клиент просит Accept: application/xml, а сервер умеет только JSON: 406 Not Acceptable. В Spring за это отвечают consumes и produces на методе контроллера, а без них Spring подбирает по зарегистрированным конвертерам и отвечает этими кодами сам. Запрос без Accept или с */* получает JSON, и это правильное умолчание: строгий 406 на отсутствующий заголовок ломает половину клиентов.
Тело ошибки при 406 это отдельная головоломка: клиент сказал, что JSON не понимает, а ошибка у вас в JSON. Spring в этом случае отдаёт пустое тело, и это допустимо; главное, чтобы 415 и 406 не превращались в 500 из-за того, что обработчик ошибок сам пытается ответить в неподдерживаемом формате.
Тем же механизмом выбирают версию представления (Accept: application/vnd.shop.v2+json, о чём статья про версионирование говорит как о варианте, от которого отказались) и язык: Accept-Language в запросе, Content-Language в ответе, и статья про локализацию сообщений строится на этом.
Сжатие это тоже согласование: клиент присылает Accept-Encoding: gzip, br, сервер сжимает и отвечает Content-Encoding: gzip с Vary: Accept-Encoding, чтобы кэш не отдал сжатое тому, кто не просил. Включается настройкой, server.compression.enabled: true с min-response-size около килобайта, потому что сжимать сто байт дороже, чем отправить. Обычно сжимает не приложение, а балансировщик или прокси перед ним, и тогда в приложении сжатие выключают, чтобы не делать работу дважды. Уже сжатое (картинки, архивы) не сжимают повторно, а ответы, где рядом лежат секрет и данные, подконтрольные пользователю, сжимать по TLS небезопасно; для JSON-API это редкий случай, но про него спрашивают на аудитах безопасности.
Коротко
- Имена полей — camelCase с суффиксом
Idу идентификаторов (orderId,customerId); длинный числовой идентификатор (больше 2^53) отдают строкой, иначе браузер его округлит. - Даты — строка ISO 8601, деньги — строкой или в копейках (внутри
BigDecimal), перечисления —UPPER_SNAKE_CASE; на входе неизвестное значение перечисления это400со внятным сообщением, на выходе клиент не должен на нём падать. - Создание —
201сLocationи телом (или202, если создание идёт в фоне, и201без тела, если оно дорогое); обновление —200с обновлённым ресурсом; удаление —204без тела. - Единичный ресурс — плоский объект без обёрток, но в корне ответа всегда объект (даже у списка без пагинации), иначе метаданные потом не добавить; вложенность держат в пределах двух уровней, а списку дают короткое представление.
nullв ответе 2xx запрещён — если данных нет, поля нет в JSON.nullв теле PATCH — особая команда «удалить поле» (JSON Merge Patch); сnon_nullтакое поле не отправится и не отличится от «не прислали» — нуженJsonNullable.- Пустые коллекции —
[], неnull; клиент обязан игнорировать незнакомые поля (в голом Jackson проверка включена и роняет разбор — её выключают явно). 415когда прислали не тотContent-Type,406когда просят формат, которого нет; безAcceptотдают JSON; сжатие включают на прокси или черезserver.compressionс порогом размера иVary: Accept-Encoding.
Что почитать дальше
- URL и ресурсы в REST API — HTTP-методы, статусы, структура URL.
- Query-параметры и пагинация — как передавать фильтры и получать страницы.
- Ошибки и проблемный ответ — формат ошибки RFC 9457.
- Заголовки и трассировка — Location, ETag и служебные заголовки.