← назад к разделу

Когда API возвращает данные, клиент ожидает предсказуемую структуру: понятные имена полей, читаемые даты, чёткие правила про отсутствующие значения. Без договорённостей каждая команда изобретает свой формат — и интеграция превращается в квест угадать, что значит null в этом поле.

В этой статье разберём конкретные правила: как называть поля, как отдавать даты, когда возвращать 201, а когда 204, и почему null в ответе — это проблема.

Ниже — один и тот же заказ в трёх форматах ответа: видно, что меняется в теле и в коде клиента, когда из ответа убирают обёртку и null-поля.

одно состояние заказа — три формата ответа {"success": true,"data": {"orderId": "550e8400-...","status": "CONFIRMED","discount": null,"comment": null,"items": []},"error": null} {"orderId": "550e8400-...","status": "CONFIRMED","discount": null,"comment": null,"items": []} {"orderId": "550e8400-...","status": "CONFIRMED","items": []} формат 1 — envelopeклиент лезет через обёртку:if (r.success) r.data.orderId"discount": null — скидки нет,не посчитали или сняли?полей в теле: 8из них null: 3 формат 2 — без обёрткиданные сразу, уровнем выше:r.orderIdно проверка та же:r.discount !== null &&r.discount !== undefinedполей в теле: 5из них null: 2 формат 3 — плюс non_nullодна настройка сериализации:default-property-inclusion: non_nullтеперь клиенту хватает:if (r.discount)"items": [] осталось — «есть, пусто»полей в теле: 3из них null: 0 8 полей, 3 из них null, данные на втором уровне вложенности обёртка ушла: 5 полей, данные сразу, но два null остались 3 поля, ни одного null: чего нет — того нет в JSON формат полей в теле из них null путь к данным envelope + null 8 3 r.data.orderId плоский объект52r.orderId плюс non_null30r.orderId

Состояние заказа во всех трёх тактах одно и то же — меняется только формат ответа. Убрали обёртку 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.

Что почитать дальше