1. API как долгоживущий контракт
Почти у каждого начинающего backend-разработчика в голове есть наивная мысль: “пока клиент не пришёл — можно менять как угодно, потом всё поправим”. Это звучит логично ровно до тех пор, пока вы сами не становитесь клиентом своего API через .http-запросы, Swagger UI, фронтенд, мобильное приложение или интеграцию коллеги из соседней команды. Контракт начинает жить раньше, чем вы успеете сказать “да это же учебный проект”.
Самая важная мысль этой лекции: API живёт дольше, чем ваш текущий код. Контроллеры перепишутся, сервисы переименуются, репозиторий “переедет” из in-memory в JPA в следующем курсе — это нормально. А вот внешние ожидания клиентов (какой URL вызывать, какие параметры передавать, какие поля ждать в JSON, какие статусы и ошибки получать) меняются больно и редко “без последствий”.
Представьте API как меню в кафе. Кухня — это ваш код: можно менять поваров местами, можно заменить сковородку, можно даже переехать в другое помещение, если вкус и состав блюд в меню не изменились. Клиенту всё равно. Но если вы переименовали “борщ” в “суп №7” и ещё поменяли рецепт так, что в нём теперь банан, клиент заметит. И будет прав.
Поэтому сегодня мы фиксируем “точку зрения клиента”: что именно он способен заметить, даже если вы сделали “маленькое изменение” в коде. А уже в следующей лекции мы научимся классифицировать изменения как безопасные или ломающие, но сначала нужно понять, что вообще является контрактом.
2. Поверхность контракта: HTTP вместо Java
Когда вы смотрите на проект изнутри, вы видите классы, пакеты, сервисы, мапперы и “красивую архитектуру”. Клиент этого не видит вообще. Он видит HTTP: путь, метод, параметры, заголовки, тело запроса и ответа, статус-коды и тип контента. Если клиент интегрирован на уровне кода (например, фронтенд или другой сервис), он ещё видит shape JSON и значения enum’ов — и на этом всё, “магия Spring” остаётся за кулисами.
Удобно держать в голове простую схему: граница контракта проходит по линии HTTP. Всё, что “по эту сторону” — ваши внутренние детали, всё “по ту сторону” — обязательства перед клиентом.
flowchart TD %% Граница контракта — HTTP между клиентом и вашим приложением Client["Клиент API
frontend / mobile / integration"] -->|HTTP request| MVC[Spring MVC] MVC --> Controller[Controller] Controller --> Service[Service] Service --> Repo[In-memory repo] Service --> Storage[File storage] Repo --> Service Storage --> Service Service --> Controller Controller -->|HTTP response| Client
То, что реально “является контрактом”, можно разложить в табличку. Она не идеальна, но отлично лечит от иллюзии “контракт = только JSON”.
| Что видит клиент | Как это выражено в нашем Spring MVC коде | Почему это часть контракта |
|---|---|---|
| URI (например, /api/v1/tasks/{taskId}) | @RequestMapping, @GetMapping и друзья | Это “адрес” ресурса. Если адрес сменился — клиент не сможет достучаться. |
| HTTP method (GET/POST/PUT/PATCH/DELETE) | mapping-аннотации (@GetMapping, @PostMapping…) | Метод несёт семантику. Клиент может кэшировать GET, повторять PUT, ждать 204 на DELETE. |
| Query params (page, size, status, q…) | @RequestParam, @ModelAttribute | Это часть входного контракта. Имя и смысл параметра — обещание. |
| Headers (Content-Type, Accept, Location, download headers) | consumes/produces, ResponseEntity, HttpHeaders | Headers управляют форматом и поведением клиента (особенно у файлов и 201 Created). |
| Request body JSON | @RequestBody, request DTO | Клиент строит payload строго по форме. Любая “мелочь” тут заметна. |
| Response JSON | response DTO, Jackson-правила | Клиент парсит ответ. Если поле пропало/переименовалось — здравствуй, баг. |
| Status codes (200/201/204/400/404/409/415/500) | ResponseEntity, global exception handling | Клиент реагирует на статус, иногда даже без чтения body. |
| Error payload | ProblemDetail + расширения (code, fieldErrors) | Ошибки — тоже контракт. Клиент часто пишет логику именно под них. |
| Multipart parts (file, metadata) | @RequestPart / MultipartFile | Это “имена полей” multipart-запроса. Если поменять — клиент сломается так же, как при переименовании JSON-поля. |
Заметьте, тут вообще нет строки “название метода в контроллере” или “структура пакетов”. Клиенту всё равно, как вы назвали метод getById() и в каком пакете он лежит. Зато клиенту не всё равно, что путь теперь другой или что status перестал быть DONE и стал COMPLETED.
3. Данные контракта: URI, query, DTO и JSON
Очень легко свести контракт API к “какой JSON мы отдаём”. И да, JSON — важнейшая часть контракта в нашем курсе. Но он всегда привязан к остальному HTTP-контексту: к URI, методам, статусам и даже к тому, что именно мы считаем “пустым результатом” для list-endpoint’ов. Поэтому здесь мы посмотрим на данные как на связку: “куда и как клиент обращается” плюс “какую форму данных он обязан отправить/получить”.
Начнём с приятного факта: внутренние имена в доменной модели могут жить своей жизнью, если DTO и mapping держат контракт стабильным. Это одна из причин, почему мы вообще делали DTO, а не сериализовали Task “как есть”.
Мини-пример: внутреннее поле переименовали (или оно исторически так называлось), но внешний контракт остаётся прежним.
public class Task {
// Внутреннее имя поля может быть каким угодно — клиент это не видит
private String ownerName;
// Внутренний getter тоже не часть HTTP-контракта
public String getOwnerName() { return ownerName; }
}
// Внешний контракт задаёт DTO: именно его поля станут ключами в JSON
public record TaskSummaryResponse(String assigneeName) {}
Если ваш mapper отдаёт assigneeName, клиент вообще не обязан знать, что внутри вы называете это ownerName. Это обычный рефакторинг “в кухне”, который не меняет “меню”.
Дальше начинаются более “скользкие” зоны, где наивный рефакторинг становится внешним изменением. Переименовать поле в response DTO — это уже не внутреннее дело, потому что имя поля = ключ в JSON. Поменять тип поля — тоже заметно: было число, стала строка, и клиент падает ещё на этапе парсинга. Поменять формат даты/времени — это вообще классика: сервер радостно отдаёт “как получилось”, а клиент потом грустит, потому что ISO-8601 внезапно стал “21.03.2026 18:00”.
И есть ещё один вид изменений, который чаще всего воспринимают как “мелочь”, но клиент всё равно заметит: добавление нового поля в response. Обычно это расширение, а не ломка (подробно разберём в следующей лекции), но клиент увидит это изменение в документации, логах и примерах — и это нормально. Важно, чтобы вы понимали: внешний контракт изменился, просто не всегда “больно”.
Вот как выглядит типичный “расширяющий” шаг в response DTO:
import java.time.Instant;
public record TaskDetailsResponse(
String id,
String title,
String status,
Instant updatedAt,
// Новое поле в ответе: старые клиенты обычно смогут его игнорировать,
// а новые — использовать (например, для бейджа количества комментариев).
Integer commentCount
) {}
Если commentCount появится в ответе, клиент, который его не знает, обычно сможет проигнорировать поле. Но клиент, который хочет “покрасить” кнопку комментариев, наконец сможет это сделать без отдельного запроса. То есть контракт стал богаче — и это тоже изменение, просто полезное.
Ещё важный слой контракта — query-параметры и их смысл. У нас есть page, size, sort, фильтры по status/priority/tag, текстовый поиск q и диапазоны дат. Имена этих параметров и правила по умолчанию — часть обещания. Если вы поменяете sort по умолчанию с updatedAt,desc на title,asc, клиент заметит: результаты будут “другими”, даже если JSON-форма не изменилась. Это тот случай, когда схема не поменялась, а поведение поменялось, и для клиента это может быть настоящей проблемой.
4. Контракт ошибок: ProblemDetail и коды
Обычно про контракт думают на happy-path: “вот запрос, вот ответ, всё красиво”. Но реальная жизнь API — это смесь успехов и ошибок. Иногда даже кажется, что ошибок больше: неправильный UUID, невалидный payload, конфликт статуса, неподдерживаемый тип файла. И если ошибки непредсказуемы, клиенту приходится либо писать “лапшу” обработчиков, либо просто показывать пользователю “что-то пошло не так” на каждую ситуацию. В обоих вариантах вы не выигрываете.
Мы уже построили единый error contract вокруг ProblemDetail и добавили туда application-specific code и, где нужно, fieldErrors. Теперь важно признать: это не “внутренняя реализация”, это публичная часть API. Клиент может опираться на status для UX (например, показать “не найдено” или “конфликт”), на code — для машинной логики (например, подсветить конкретную причину), на fieldErrors — чтобы подсказать пользователю, что именно исправить.
Если формализовать, то для клиента полезно, когда error payload стабилен по структуре. Примерно так (без привязки к конкретному классу): type, title, status, detail, instance как стандартные поля ProblemDetail, плюс наш code, плюс опциональные расширения вроде timestamp и fieldErrors. Даже если человек читает ошибку глазами, клиентский код обычно читает status и code, а текстовые поля (title/detail) использует как “человеческую подсказку”.
Именно поэтому “маленькое” изменение вроде переименования error code — это не косметика. Если вчера клиент обрабатывал TASK_NOT_FOUND, а сегодня вы решили, что “красивее звучит” TASK_MISSING, то клиент перестанет узнавать ситуацию. Вроде бы всё ещё 404, но бизнес-логика на стороне клиента (или интеграции) может быть завязана на code, и она сломается.
То же самое относится к структуре fieldErrors. Если клиент ожидает, что там будут ключи вида title или tags[0], а вы внезапно поменяли формат на “человеческие” сообщения без путей, то вы отобрали у клиента возможность точно привязать ошибку к полю формы. И да, это тоже изменение контракта, даже если серверный код “стал проще”.
И ещё один нюанс: ошибки multipart и download — тоже часть error contract. Если upload-эндпоинт раньше возвращал 415 с code=UNSUPPORTED_MEDIA_TYPE, а вы начали возвращать 400 INVALID_INPUT “потому что так проще”, клиент заметит. Он может показывать разные сообщения пользователю, или, например, автоматически предлагать другой формат файла только при 415.
5. Контракт файлов: multipart и download
Файловые эндпоинты часто воспринимают как что-то второстепенное: “ну это же просто MultipartFile, что там документировать и считать контрактом”. На практике это один из самых контрактно-чувствительных сценариев, потому что он резко отличается от обычного JSON: другой Content-Type, другой способ передачи данных, иногда другие клиенты (браузер, мобильное приложение, интеграция). И если вы тут “чуть-чуть поменяли”, то клиент падает с особенно загадочным выражением лица.
У нас upload выглядит как multipart/form-data с двумя частями: бинарный файл и JSON-метаданные. Важнейшая деталь: имена частей (file, metadata) — это ровно такой же контракт, как имена JSON-полей. Если вы переименуете part metadata в meta, старый клиент будет отправлять metadata, и сервер внезапно начнёт отвечать ошибкой “missing part”. Внутри проекта вы поменяли одну строку, а снаружи у клиента перестал работать ключевой сценарий.
Мини-пример, который очень наглядно показывает “контрактность” multipart: прямо в сигнатуре видно, что клиент обязан прислать part с конкретным именем.
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
@PostMapping(
path = "/api/v1/tasks/{taskId}/attachments",
// Важно: клиент должен прислать именно multipart/form-data, иначе будет 415/400 в зависимости от обработки
consumes = MediaType.MULTIPART_FORM_DATA_VALUE
)
public void upload(
// Важно: имя part — часть публичного контракта ("file")
@RequestPart("file") MultipartFile file,
// Важно: имя part — часть публичного контракта ("metadata")
@RequestPart("metadata") AttachmentUploadMetadataRequest metadata
) {
// Здесь может быть любая внутренняя реализация — контракт задаётся HTTP-сигнатурой выше
}
Даже если вы ничего не знаете про Spring, вы уже можете “прочитать контракт”: запрос multipart, обязательны две части, названия фиксированы.
Download-сценарий тоже контрактный. Там нет JSON, зато есть headers. Content-Type говорит клиенту, что это за данные, Content-Disposition — как назвать файл и нужно ли его скачивать, а не пытаться открыть как страницу. Если вы перестали отдавать Content-Disposition, браузер может начать открывать PDF в табе, а не скачивать, и пользователь скажет “сломалось скачивание”. Технически сервер “отдал файл”, но контракт поведения для клиента изменился.
А ещё есть “мелочи”, которые не мелочи: если вы изменили fallback content type с application/octet-stream на text/plain, клиент может начать неправильно обрабатывать бинарные данные. Или если вы поменяли endpoint /download на /content ради эстетики — клиент заметит, потому что URL изменился.
6. Рефакторинг и изменение API: как проверить
Самый опасный момент — это когда вы искренне считаете, что сделали “чисто внутренний рефакторинг”, а клиент внезапно сломался. Обычно так бывает, когда вы смотрите на изменения глазами сервера: “я же только переименовал”, “я же только поменял дефолт”, “я же только убрал поле, оно же лишнее”. Клиент смотрит иначе: “у меня запрос был таким — теперь он не работает”, или “ответ был такой — теперь я не могу его распарсить”.
Простейший практический тест звучит почти смешно, но он работает: если после изменения вам нужно править ваши .http-запросы (или фронтенд-код клиента), значит контракт поменялся. И не важно, что вы “ничего такого” не делали — клиенту всё равно.
Чтобы легче было себя ловить, полезна таблица “изменение → клиент заметит?”. Здесь нет темы safe/breaking (это в следующей лекции), тут только факт заметности.
| Изменение в коде | Клиент заметит? | Почему |
|---|---|---|
| Переименовали метод TaskService.getById() в findById() | Нет | Это не выходит наружу; HTTP не изменился. |
| Перенесли класс маппера в другой пакет | Нет | Клиент не видит Java-пакеты. |
| Переименовали поле в domain-модели ownerName -> assigneeName, но DTO не меняли | Нет | DTO и JSON остались прежними. |
| Поменяли @RequestMapping("/api/v1/tasks") на "/api/v1/work-items" | Да | URL ресурса изменился — клиент будет стучаться “в пустоту”. |
| Переименовали query param q в query | Да | Старый клиент продолжит отправлять q, а сервер перестанет фильтровать. |
| Переименовали JSON-поле title в taskTitle в response DTO | Да | Клиент парсит по ключам; ключ исчез. |
| Поменяли HTTP status для удаления с 204 на 200 и начали возвращать тело | Да | Клиент может ожидать пустое тело и другой статус (особенно в тестах/SDK). |
| Поменяли error code с TASK_NOT_FOUND на TASK_MISSING | Да | Клиентская логика по кодам перестаёт работать предсказуемо. |
| Переименовали multipart part metadata в meta | Да | Клиент отправляет старое имя части — сервер не видит данные. |
| Поменяли default сортировку в GET /tasks | Да | Тот же запрос начинает давать другой результат, хотя схема ответа та же. |
И вот здесь как раз всплывает “коварная” мысль: контракт — это не только структура DTO, но и поведение. Даже если JSON одинаковый, но “тот же запрос” ведёт себя по-другому, клиент это почувствует. Иногда сразу (тесты упали), иногда через неделю (пользователь сказал, что “раньше было удобнее”).
Напоследок закрепим самый очевидный пример внешнего изменения: путь ресурса. Код в контроллере может быть пустым, но клиент уже сломался, потому что изменился адрес.
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
// Важно: URI ресурса — часть контракта; его смена ломает клиентов
@RequestMapping("/api/v1/work-items") // было: /api/v1/tasks
class TaskController {
// Внутренности контроллера могут меняться, но URI — то, что видит клиент
}
Для сервера это выглядит как “переименовали ресурс, ну бывает”. Для клиента это выглядит как “приложение умерло”. И ровно поэтому мы так педантично фиксировали URI ещё в самом начале курса.
С этим пониманием мы готовы к следующей лекции, где уже будем различать изменения, которые расширяют контракт относительно безопасно, и изменения, которые почти гарантированно ломают клиентов. Но это будет следующий шаг; сейчас важно, что вы научились видеть поверхность контракта целиком.
7. Типичные ошибки при эволюции API
Ошибки в этой теме обычно происходят не из злого умысла, а из-за “туннельного зрения”: вы смотрите на API как разработчик сервера, а не как разработчик клиента. Из-за этого вы принимаете решения “по красоте кода”, а последствия проявляются только снаружи — в фронтенде, интеграции или даже в ваших же .http-сценариях, которые вдруг перестали работать.
Ошибка №1: считать контрактом только JSON, игнорируя URI, методы, статусы и headers.
Если думать “контракт = DTO”, очень легко сломать клиента изменением статуса (например, 204 на 200), переносом ресурса на другой путь, сменой Content-Type или исчезновением Location на 201 Created. Клиент живёт в HTTP-мире, и всё это для него такие же важные сигналы, как и поля JSON.
Ошибка №2: путать внутренний рефакторинг и внешнее изменение, ориентируясь на IDE и компилятор.
IDE радуется: всё компилируется, тесты сервера зелёные. Но клиенту от этого не легче. Правильная проверка здесь не “собралось ли”, а “смог бы старый клиент отправить тот же запрос и получить ожидаемый ответ”. Иногда достаточно просто открыть старые .http-запросы и честно попробовать их, прежде чем говорить “ничего не изменилось”.
Ошибка №3: менять смысл поля, не меняя его имени, и считать это “безопасным”.
Например, поле archived раньше означало “статус ARCHIVED”, а теперь означает “просто скрыто из списка”. Формально JSON-ключ тот же, но семантика другая, и клиент, который строил поведение на старом смысле, начнёт вести себя странно. Контракт — это не только форма, но и смысл.
Ошибка №4: относиться к error contract как к “внутренней деталюшке” и переименовывать code или формат fieldErrors без сожаления.
Ошибки — это то, на что клиент пишет ветвления. Если вы поменяли code, убрали fieldErrors или начали возвращать разные форматы для разных контроллеров, клиент или перестанет различать ситуации, или начнёт писать костыли. В итоге страдает не только клиент, но и ваш же API: его сложнее тестировать и документировать.
Ошибка №5: считать multipart и download “особым случаем”, который не требует такой же дисциплины, как JSON.
В multipart-контракте есть свои “имена полей” (parts), свои обязательные элементы, свои media types и свои ошибки (415, missing part). В download-контракте есть headers, которые управляют поведением клиента. Если относиться к этому как к “ну оно как-нибудь”, вы почти гарантированно получите “оно как-нибудь сломалось”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ