1. Обратная совместимость: ломается без компилятора
Когда мы работаем над проектом, очень легко попасть в ловушку: “Я же всё поправил в одном месте, у меня тесты зелёные, значит всё ок”. Но внешний клиент вашего API живёт в другой реальности. Он не обязан деплоиться вместе с вами, не обязан читать ваши миграционные заметки, и вообще может быть написан на языке, где ваш новый JSON-пейлоад превратится в “неожиданный сюрприз” без шанса на переговоры.
Обратная совместимость (backward compatibility) — это про простую вещь: старый клиент продолжает работать с новой версией сервера. Причём “работать” — это не “иногда отвечает 200”. Это означает, что клиент может отправлять те же запросы и получать ответы в том виде и смысле, который был ему обещан контрактом. И вот тут начинается самое интересное: иногда ломает не “схема”, а семантика. Формально поле осталось, но его смысл поменялся — и клиент ломается так же уверенно, как если бы вы удалили поле совсем.
Удобно смотреть на совместимость как на пересечение двух плоскостей: что клиент отправляет (request) и что он получает (response). Для request часто критична “строгость”: сервер внезапно сделал поле обязательным — и старый клиент, который его не отправлял, получает 400. Для response часто критична “предсказуемость”: вы переименовали поле, поменяли тип или начали возвращать другой статус — и клиент, который парсит ответ, падает или ведёт себя неправильно. А ещё есть третья плоскость — ошибки: ProblemDetail, code, fieldErrors тоже часть контракта. Если вы “косметически” переименовали TASK_NOT_FOUND в TASK_DOES_NOT_EXIST, клиент, который по коду выбирает UI-сообщение или ветку логики, внезапно начинает вести себя как студент на экзамене: “я всё понимаю, но ничего не работает”.
2. Safe changes: расширяем контракт без ломки
В идеальном мире эволюция API выглядит как расширение: вы добавляете новые возможности, но старые сценарии продолжают работать. Это и есть безопасное (safe) изменение — additive: оно добавляет что-то новое, не ломая то, что было. Важно: “безопасное” не означает “вообще без риска”, но означает “по контракту старые запросы всё ещё валидны, а старые ответы всё ещё распознаваемы”.
Ниже — очень практическая таблица, которую удобно держать в голове, пока рука тянется “чуть-чуть переименовать поле, чтобы было красивее”.
| Изменение | Что происходит для старого клиента | Обычно это… |
|---|---|---|
| Добавили новый endpoint | Старый клиент его не вызывает — ему всё равно | безопасно |
| Добавили необязательный query-параметр | Старый клиент его не шлёт — поведение прежнее | безопасно |
| Добавили новое поле в response DTO | Старый клиент чаще всего его игнорирует | обычно безопасно |
| Добавили необязательное поле в request DTO | Старый клиент его не шлёт — запрос всё ещё валиден | обычно безопасно |
| Добавили новый ProblemDetail-code для нового сценария | Старый клиент может не знать код, но может обработать “как неизвестную ошибку” | часто безопасно, но зависит от клиента |
Ключевое слово тут — “чаще всего”. В реальности безопасность зависит от того, насколько клиенты толерантны к неизвестным полям и значениям. Некоторые клиенты парсят JSON в “строгие” структуры и падают на неизвестных полях. Другие — нормальные и просто пропускают неизвестное. Сервер это контролировать не может, поэтому хороший API-дизайн делает изменения максимально предсказуемыми и минимально агрессивными.
Мини‑пример: безопасное расширение response DTO
Представим, что клиентам часто нужно показывать “сколько комментариев у задачи”, но мы не хотим заставлять их делать отдельный запрос за комментариями ради одного числа. Самый спокойный вариант — добавить поле commentCount в TaskDetailsResponse. Старые клиенты продолжат читать id/title/status и не обязаны знать про новое поле.
import java.time.Instant;
// DTO ответа: добавили поле без удаления/переименования старых — это additive change
public record TaskDetailsResponse(
String id,
String title,
String status,
Integer commentCount, // новое поле: старые клиенты обычно игнорируют
Instant createdAt
) {}
Если клиент не знает commentCount, он его просто игнорирует (в нормальном мире). Если знает — начинает использовать. Красота.
Мини‑пример: безопасное расширение через новый endpoint
Добавить endpoint вроде GET /api/v1/tasks/{taskId}/comments/count (условно) — это тоже additive change. Старые клиенты продолжают жить на старых URI, новые — получают новый инструмент. Тут важно не конфликтовать по URI и не менять существующий endpoint “под другим смыслом”.
3. “Серые” изменения: формально совместимо, но иначе
Есть особенно коварный класс изменений: вы не поменяли ни путь, ни DTO, ни статус-код… но поменяли поведение так, что клиент в итоге страдает. Это тот случай, когда в команде звучит фраза: “Мы же ничего не ломали!” — и в этот момент где-то грустно плачет мобильное приложение.
Самый типичный пример — изменение default-поведения. Допустим, ваш GET /api/v1/tasks по умолчанию сортировал по updatedAt,desc, а вы решили “давайте лучше по createdAt,desc, так логичнее”. Формально контракт не менялся: query-параметр sort как был, так и есть. Но клиент, который не передаёт sort, вдруг получает другой порядок. Если UI показывал “последние изменения”, он стал показывать “последние созданные”. Ошибка? Нет. Breaking change? По сути — да, просто не в форме схемы.
Аналогично опасны изменения вроде “теперь page будет one-based, потому что людям так привычнее”. Вы даже можете оставить поле page, но поменять семантику. Клиент, который посылал page=0, внезапно начинает получать не первую страницу, а… “нулевую”, которой не существует, или сервер решает “ну ладно” и возвращает первую, но тогда page в ответе перестаёт быть честным. И у вас появляется не API, а психологический триллер.
Туда же относятся изменения в ошибках. Например, раньше вы на несуществующую задачу отдавали 404 TASK_NOT_FOUND, а теперь решили: “А давайте на удалённую задачу будем отдавать 410 Gone” или “а давайте 400”. Семантически это уже другое обещание. Клиент мог выбирать ветку поведения по статусу: 404 — показать “не найдено”, 409 — показать “конфликт”, 400 — подсветить форму. Поменяли статус — поменяли клиентское поведение.
Чтобы не жить в этом сером тумане, полезно иметь внутреннее правило: если вы меняете default-поведение, стоит относиться к этому почти так же серьёзно, как к изменению DTO. По-хорошему, это либо отдельный параметр (“новое поведение включается явно”), либо deprecation-стратегия.
4. Breaking changes: реальная ломка контракта
Breaking change — это изменение, после которого существующий клиент, который работал по прежнему контракту, перестаёт работать или начинает работать неправильно. И да, “перестаёт работать” — это не только 500. Иногда это “тихо неправильно показывает данные”, и это даже хуже: ошибка незаметна, но последствия реальные.
У breaking changes есть несколько очень повторяемых категорий. Их удобно держать в виде матрицы (и держать себя за руку, когда хочется “просто переименовать поле для красоты”).
| Категория | Пример | Почему ломает |
|---|---|---|
| Переименование/удаление поля в response | title → taskTitle | клиент ищет старое имя |
| Изменение типа поля | commentCount: number → commentCount: string | парсинг/валидация ломаются |
| Изменение обязательности request поля | поле стало обязательным | старый клиент не отправляет |
| Изменение URI | /api/v1/tasks → /api/v1/work-items | запросы идут “в пустоту” |
| Изменение статус‑кода | 204 → 200 или наоборот | клиентские ветки по статусу |
| Изменение media type | application/json → другое | клиент может не понимать |
| Изменение enum‑значений | IN_PROGRESS → INPROGRESS | клиент не узнаёт значение |
| Переименование error code | TASK_NOT_FOUND → TASK_MISSING | клиент ломается по коду |
Мини‑пример: переименование JSON‑поля (ломающее)
// Было: поле называлось title и клиенты парсили JSON "title"
public record TaskSummaryResponse(
String id,
String taskTitle // стало: taskTitle — клиенты, ожидающие title, потеряют значение
) {}
Да, “taskTitle звучит красивее”. Но клиент, который ожидал JSON "title": "...", теперь получает "taskTitle": "...". Если он отображал заголовок — он начнёт показывать пустоту. Это классический breaking change, причём один из самых болезненных: он выглядит как “маленький рефакторинг”, а по факту — смена контракта.
Мини‑пример: смена path (ломающее)
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
// Внешний контракт: изменение base path = все старые клиенты пойдут по старому URI и получат 404
@RequestMapping("/api/v1/work-items") // раньше было /api/v1/tasks
class TaskController {
}
Бизнес-логика может остаться прежней. DTO могут быть прежними. Но клиент стучится по старому адресу и получает 404. Для него вы “сломали продукт”.
Мини‑пример: изменение семантики status code
Если вы раньше делали:
- DELETE /api/v1/tasks/{taskId} → 204 No Content
а потом “для удобства” решили возвращать 200 OK с телом "deleted": true, то вы ломаете клиентов, которые по 204 понимали, что тела нет и парсить нечего. И это не шутка: многие HTTP-клиенты и SDK по-разному обрабатывают 204. Контракт на статус — это не декоративная табличка в RFC, а часть договора.
5. Deprecation: выводим из употребления аккуратно
Deprecation (устаревание) — это очень взрослое состояние контракта: вы признаёте, что какое-то поле/endpoint/параметр больше не является каноническим, но ещё не удаляете его, чтобы не ломать клиентов “в моменте”. Если safe change — это “добавили и живём”, то deprecation — это “мы хотим изменить курс, но сделаем это аккуратно”.
Устаревание полезно, когда вы понимаете, что старый элемент контракта мешает: дублирует смысл, вводит путаницу, заставляет клиентов писать костыли или не даёт эволюционировать API. Но вместо резкого “удалили и всё” вы делаете постепенный сценарий: обозначили, что устарело, дали альтернативу, некоторое время поддерживаете оба пути, потом (в идеале) удаляете в следующей мажорной версии.
Важно, что @Deprecated — это не магическое заклинание. Оно полезно для вашей команды и для Java-кода, но внешнему клиенту само по себе ничего не объясняет. Клиент не видит вашу аннотацию. Поэтому deprecation — это комбинация инженерных сигналов: вы помечаете в коде, документируете (сегодня ещё без деталей инструментов), и держите чёткий “заменитель” устаревшего элемента.
Полезно воспринимать deprecation как “жёлтый сигнал светофора”. Это не “немедленно стоп”, но и не “едем как раньше”. Это “мы меняем направление, успей перестроиться”.
Мини‑пример: устаревшее поле в response DTO
В Task Tracker API у нас есть понятие архивности. Мы уже знаем, что источник истины — status, где есть ARCHIVED. Но иногда в API хочется “удобное булево” archived. Оно действительно удобно, но может стать источником путаницы: если у вас есть status и есть archived, клиент начинает задавать вопросы уровня “а если archived=true, status точно ARCHIVED?”. Чтобы постепенно уйти от дублирования, поле можно объявить устаревшим.
import java.time.Instant;
// DTO ответа: поле ещё возвращаем, но явно помечаем как неканоническое
public record TaskDetailsResponse(
String id,
String title,
String status,
/** @deprecated Используйте status=ARCHIVED вместо archived. */
@Deprecated(forRemoval = false, since = "1.1")
Boolean archived,
Instant updatedAt
) {}
Здесь мы не удалили поле, не сломали клиента, но явно в коде зафиксировали: “это больше не основной путь”.
Мини‑пример: tolerant input через alias для плавной миграции
Если вы когда-то называли поле ownerName, а теперь хотите assigneeName, удалять старое имя из request сразу — больно. Гораздо мягче — принимать оба, но иметь одно каноническое.
import com.fasterxml.jackson.annotation.JsonAlias;
// Request DTO: принимаем старое имя поля, чтобы не ломать старых клиентов
public record TaskCreateRequest(
String title,
@JsonAlias("ownerName") // старое имя поля, которое ещё принимаем
String assigneeName // новое каноническое имя внутри сервиса
) {}
Это классический приём “мягкой совместимости”: сервер терпеливо принимает старый формат, но в своих DTO и дальше в системе живёт с новым, более точным именем.
6. Мини‑стратегия для Task Tracker API
Если собрать всё в одну практическую картину, то для нашего учебного Task Tracker API логика эволюции может выглядеть так: мы максимально долго стараемся жить в режиме safe additive changes, а когда понимаем, что нужен разворот — используем deprecation, и только если без ломки никак — тогда уже говорим о “новой версии контракта” (в реальной жизни это часто /api/v2, но сегодня мы сознательно не уходим в multi-version management).
Ниже — небольшая схема‑шпаргалка, которая хорошо укладывается в голову. Её удобно мысленно прогонять каждый раз, когда вы меняете публичный DTO или контроллер.
flowchart TD
A[Хочу изменить API] --> B{"Старые запросы/ответы останутся валидными?"}
B -- да --> C{"Меняется ли поведение по умолчанию?"}
C -- нет --> D[Safe additive change Делаем и живём]
C -- да --> E["Серое изменение Лучше сделать явно через параметр/флаг или через deprecation"]
B -- нет --> F{"Можно оставить старый путь временно?"}
F -- да --> G[Deprecation Старое остаётся, новое добавляется, даём миграцию]
F -- нет --> H[Breaking change Нужна версия/мажорное обновление клиента]
Пример “здорового” safe change в нашем проекте
Добавим commentCount в TaskDetailsResponse и аккуратно проложим маппинг. Даже если у вас нет сложной доменной логики, важно, чтобы изменения выглядели “как обычно”: DTO поменяли, маппер обновили, сервис дал данные.
public class TaskMapper {
public TaskDetailsResponse toDetails(Task task, int commentCount) {
// commentCount приходит отдельным числом (например, из агрегирующего запроса),
// чтобы контроллер не лез в репозитории напрямую
return new TaskDetailsResponse(
task.getId(),
task.getTitle(),
task.getStatus().name(),
commentCount, // новое поле в DTO: прокидываем явно
task.getUpdatedAt()
);
}
}
Здесь идея простая: мы не заставляем контроллер лазить в репозитории. Контроллер по‑прежнему тонкий, сервис собирает данные, маппер формирует DTO. Изменение “добавили поле” не превращается в архитектурный компромисс.
Пример deprecation для “удобного, но дублирующего” archived
Чтобы не ломать клиентов, мы продолжаем возвращать archived, но явно показываем, что основной источник истины — status. В коде это выражается так: archived вычисляется из status, а не живёт “сам по себе”.
public class TaskMapper {
public TaskDetailsResponse toDetails(Task task) {
// archived — производное поле: вычисляем из status, чтобы не было расхождения
boolean archived = task.getStatus() == TaskStatus.ARCHIVED;
return new TaskDetailsResponse(
task.getId(),
task.getTitle(),
task.getStatus().name(),
archived, // deprecated, но ещё есть для старых клиентов
task.getUpdatedAt()
);
}
}
Клиентам, которые уже перешли на status, поле archived вообще не нужно. Клиенты, которые ещё не перешли, продолжают работать. Сервер при этом не рискует “разъехаться” в значениях.
Пример deprecation для старого endpoint (гипотетически)
Иногда вы понимаете, что раньше вы назвали ресурс плохо. Например, кто-то когда-то сделал /api/v1/work-items, а теперь вы хотите канонический /api/v1/tasks. Полностью удалять старый endpoint — это ломка. Мягче — оставить старый контроллер как “legacy”, а новый сделать основным.
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Deprecated(since = "1.2", forRemoval = true) // сигнал команде: уберём позже, но не сейчас
@RestController
@RequestMapping("/api/v1/work-items") // старый URI продолжаем поддерживать ради обратной совместимости
class LegacyWorkItemController {
// Делегируем в тот же сервис, что и новый TaskController
}
Это не означает, что вы обязаны так делать всегда. Это означает, что у вас появляется инструмент: “сохраняем обратную совместимость, но показываем направление”.
7. Типичные ошибки при изменениях API
Ошибка №1: считать, что “раз код компилируется — значит изменение безопасное”.
Компилятор проверяет ваш Java-код, но не проверяет внешних клиентов, их JSON-парсеры и их ожидания от статус-кодов. Можно идеально отрефакторить проект и при этом полностью сломать контракт, просто переименовав поле в DTO. У API нет “общего компилятора” между вами и внешним миром — контракт и есть ваш единственный мост.
Ошибка №2: менять поведение по умолчанию так, будто это внутренняя деталь.
Изменение default сортировки, default фильтра, default размера страницы часто выглядит как “улучшение”. Но для клиента это изменение смысла запроса без изменения самого запроса. В результате один и тот же запрос начинает означать другое. Если уж менять default‑поведение, лучше делать это либо явно через параметры, либо через период deprecation с очень понятной коммуникацией.
Ошибка №3: переименовывать error code, потому что “так красивее”.
В ProblemDetail наш code — это машинно‑читаемый ключ. Клиенты вполне могут делать if (code == TASK_NOT_FOUND) .... Переименование кода без периода совместимости — такой же breaking change, как переименование JSON‑поля в успешном ответе. Если уж вы вынуждены переименовать, думайте в сторону deprecation: старый код ещё какое-то время возвращается (или возвращается как alias), а новый становится каноническим.
Ошибка №4: “пометили @Deprecated и успокоились”, но не дали замену.
Устаревание без альтернативы — это как табличка “проход запрещён”, повешенная посреди коридора без указателя “идите направо”. Если вы объявляете что-то устаревшим, у клиента должен быть ясный новый путь: новое поле, новый endpoint, новое значение, новый параметр. И желательно так, чтобы миграция была механической, а не требовала догадок.
Ошибка №5: удалять устаревшее сразу, потому что “всё равно никто не пользуется”.
Эта фраза почти всегда означает “мы не знаем, кто пользуется”. Внешние клиенты не обязаны светиться в ваших логах удобным способом. Поэтому deprecation — это отдельный период жизни контракта. Даже в учебном проекте полезно привыкать к мысли: устаревание — это состояние, а удаление — событие, которое должно быть осмысленным.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ