Клієнта ламає поведінка, а не номер у шляху. Тому починати варто з переліку змін, які ваша команда вважає несумісними, і цей перелік має бути записаний. Мінімальний робочий список: видалення чи перейменування поля відповіді, звуження типу (був рядок або число, став тільки число), поле, яке раніше ніколи не бувало null, а тепер буває, нове обовʼязкове поле в запиті, будь-яка суворіша валідація вхідних даних, інший HTTP-статус на той самий сценарій, змінена структура помилки, інше дефолтне сортування чи розмір сторінки, зміна семантики поля без зміни назви. Остання найпідступніша: total раніше був без податку, тепер із податком, схема ідентична, а гроші в клієнта рахуються неправильно. Жоден лінтер такого не зловить, рятує тільки дисципліна ревʼю.
Зворотний бік цієї домовленості - правило tolerant reader. Клієнт зобовʼязаний ігнорувати невідомі поля, не покладатися на порядок ключів у JSON і не падати на нових значеннях там, де контракт прямо дозволяє розширення. Це правило пишуть у документації API і дотримуються його у власних клієнтах. Будувати реліз на припущенні, що всі чужі інтеграції такі ж дисципліновані, не варто: типовий партнерський код на PHP розбирає відповідь через match по статусу, а типовий Go-клієнт має згенеровану структуру зі строгим enum. Через це додати поле у відповідь майже завжди безпечно, а додати нове значення у поле, за яким клієнт розгалужується, вже ні. Якщо набір значень має рости, це закладають у контракт заздалегідь: документований unknown-фолбек або окреме булеве поле замість розширення enum.
Питання, де жити версії, радше операційне, ніж філософське. URI-префікс /api/v1 виграє тим, що версія видима в логах, у трасуванні, в кеші CDN і в curl без додаткових заголовків; його недолік у тому, що один ресурс отримує кілька адрес. Версія в медіатипі (Accept: application/vnd.acme.orders+json;version=2) зберігає один URL, але тягне Vary: Accept через усі проксі й ускладнює життя інтеграторам. Для публічних API з партнерами практика давно збіглася на URI. Куди важливіше за сам механізм те, що саме ви версіонуєте. Найдорожча помилка тут - копіювати весь стек під V2: домен має лишатися в однині, а версія жити в шарі представлення (окремі JsonResource у Laravel, окремі DTO і serializer-групи у Symfony) та у валідації вхідних даних. Коли версій стає більше двох, окремі класи-представлення перетворюються на ланцюжок конвертерів поверх однієї канонічної форми, як у датованому версіонуванні Stripe.
Всередині версії несумісні зміни котять через expand-contract, той самий прийом, що й у міграціях бази. Спершу нове поле зʼявляється поруч зі старим, обидва заповнюються з одного джерела. Потім старе позначають застарілим у специфікації і дивляться в телеметрію, хто його ще читає. Прибирають уже в наступній major. Цикл довший за «просто перейменували», зате одне поле не тягне за собою нову версію всього API, і саме завдяки цьому /v1 живе роками.
Перевірку сумісності краще зняти з ревʼюера і віддати CI. Специфікація OpenAPI має бути артефактом збірки, а не окремим документом, який хтось править руками; на кожен PR ганяється breaking-diff проти головної гілки (oasdiff breaking, openapi-diff), і pipeline падає на видаленому полі. Поверх цього добре лягають снапшот-тести, які фіксують відповідь застарілої версії цілком: будь-яка випадкова зміна серіалізації одразу видима в діфі. Найсильніший рівень дають consumer-driven contract тести (Pact): очікування публікує сам споживач, тож ви перевіряєте сумісність не зі схемою, а з тим, що реально читають клієнти.
Вимкнення версії - окрема робота, і починається вона з телеметрії: версія та ідентифікатор клієнта пишуться в лог на кожному запиті, а трафік має розбивку. Далі на відповідях старої версії зʼявляються Deprecation (RFC 9745, structured field date виду @1767225600) і Sunset (RFC 8594, звичайна HTTP-date) плюс Link із rel="deprecation" на гайд міграції. За кілька тижнів до дати стають у пригоді brownout-вікна: короткі проміжки, коли /v1 повертає 410 із посиланням на документацію. Вони знаходять саме тих клієнтів, які не читають розсилку, зате мають алерти. Скільки версій тримати паралельно, вирішує ціна підтримки; більшість команд зупиняється на двох, бо кожна додаткова множить матрицю тестів. Для внутрішніх сервісів, які деплояться разом, номери версій часто взагалі зайві: там дешевше жити на одній версії з expand-contract і contract-тестами, а повний набір правил повертається рівно тоді, коли серед клієнтів зʼявляються мобільні збірки, які ви не можете оновити.
// routes/api.php: версія у шляху, бізнес-логіка одна на всі версії
Route::prefix('v1')->middleware(AnnounceSunset::class)->group(function () {
Route::get('orders/{order}', ShowOrder::class)->defaults('apiVersion', 1);
});
Route::prefix('v2')->group(function () {
Route::get('orders/{order}', ShowOrder::class)->defaults('apiVersion', 2);
});
final class ShowOrder
{
public function __invoke(Request $request, Order $order): JsonResource
{
// Версіонується лише представлення; жодних гілок if у домені
return match ($request->route()->defaults['apiVersion']) {
1 => new OrderV1Resource($order),
2 => new OrderV2Resource($order),
};
}
}
final class OrderV1Resource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->public_id,
'total' => (string) $this->total_minor / 100, // історичний формат, заморожений
'status' => $this->status->value,
];
}
}
final class OrderV2Resource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => $this->public_id,
'total' => ['amount' => $this->total_minor, 'currency' => $this->currency],
'status' => $this->status->value,
];
}
}
final class AnnounceSunset
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
// RFC 9745: structured field date, секунди від епохи з "@"
$response->headers->set('Deprecation', '@1767225600');
// RFC 8594: звичайна HTTP-date
$response->headers->set('Sunset', 'Wed, 01 Jul 2026 00:00:00 GMT');
$response->headers->set('Link', '<https://docs.example.com/api/v2>; rel="deprecation"');
return $response;
}
}
Скажіть, що версіонуєте контракт, а не код, і назвіть три речі, без яких вимкнення старої версії - лотерея: перелік breaking-змін, який перевіряє CI по OpenAPI-діфу; заголовки `Deprecation` і `Sunset` на кожній відповіді застарілої версії; дашборд із розбивкою трафіку по версії та клієнту.