Введение
Вы выпустили API. У вас 50 клиентов. Пришло время обновить: переименовать поле, изменить формат даты, убрать устаревший эндпоинт. Но каждое такое изменение может сломать все 50 интеграций.
Обратная совместимость — это искусство развивать API, не ломая существующих клиентов. Twitter, Stripe, Google — все крупные API-провайдеры годами поддерживают старые версии, одновременно выпуская новые.
В этой статье разберём:
- Что считается breaking change, а что — нет
- Три стратегии версионирования API
- Как грамотно объявлять deprecation
- Паттерны безопасной эволюции
- Чек-лист перед каждым релизом
📋 Содержание
Что такое обратная совместимость
Обратная совместимость (backward compatibility) — свойство API, при котором обновление не ломает существующих клиентов. Клиент, написанный для v1, продолжает работать после обновления сервера.
Принцип Робастности (Postel's Law)
«Будь либерален к тому, что принимаешь, и консервативен к тому, что отправляешь»
Это основа обратной совместимости: сервер должен принимать любой валидный запрос старого формата и возвращать ответ, который старый клиент сможет обработать.
Почему это так важно:
- Доверие клиентов — никто не хочет переписывать код каждый месяц
- Мобильные приложения — нельзя заставить пользователей обновить приложение мгновенно
- Микросервисы — нельзя обновить 20 сервисов атомарно
- Партнёрские интеграции — внешние компании обновляются медленно
- SLA и контракты — поломка API может нарушить юридические обязательства
Breaking vs Non-breaking changes
Понимание разницы — фундамент для безопасных обновлений:
Breaking changes (ломающие изменения)
Любое изменение, которое может вызвать ошибку у существующего клиента:
| Изменение | Тип | Почему ломает |
|---|---|---|
| Удаление эндпоинта | BREAKING | Клиент получит 404 |
| Удаление поля из ответа | BREAKING | Клиент обращается к несуществующему полю |
| Переименование поля | BREAKING | Старое имя больше не существует |
Изменение типа поля (string → number) |
BREAKING | Клиент парсит неправильно |
| Новый обязательный параметр запроса | BREAKING | Старые запросы без этого параметра → 400 |
| Изменение URL эндпоинта | BREAKING | Старый URL → 404 |
| Изменение формата ошибок | BREAKING | Клиентский обработчик ошибок ломается |
| Изменение HTTP-статусов | BREAKING | Клиент обрабатывает другой код |
Non-breaking changes (безопасные изменения)
| Изменение | Тип | Почему безопасно |
|---|---|---|
| Добавление нового поля в ответ | SAFE | Клиент игнорирует неизвестные поля |
| Добавление нового эндпоинта | SAFE | Старые эндпоинты работают |
| Добавление необязательного параметра | SAFE | Старые запросы проходят валидацию |
| Расширение enum новыми значениями | CAUTION | Безопасно, если клиент обрабатывает неизвестные значения |
| Увеличение лимитов (max length, rate limit) | SAFE | Более мягкие ограничения не ломают |
| Улучшение текста ошибок | SAFE | Если клиент парсит код ошибки, а не текст |
⚠️ Серая зона: добавление нового значения enum
Если вы добавите новый статус "refunded" в поле status, а клиент обрабатывает только "active" и "cancelled" через switch/case без default — клиент может сломаться. Всегда документируйте, что список значений может расширяться.
Стратегии версионирования
Когда breaking change неизбежен — нужна новая версия. Три основных подхода:
1. Версия в URL (URL Path Versioning)
GET /api/v1/users
GET /api/v2/users
Плюсы
- Максимально наглядно
- Легко тестировать в браузере
- Просто роутить (Nginx, API Gateway)
- Легко кэшировать (CDN)
Минусы
- URL меняется при смене версии
- Дублирование кода между версиями
- Не «чистый» REST (версия — не ресурс)
Кто использует: Stripe (/v1/), Twitter (/2/), GitHub (/v3/), Google (/v1/)
2. Версия в заголовке (Header Versioning)
GET /api/users
Accept: application/vnd.myapi.v2+json
# Или кастомный заголовок:
GET /api/users
X-API-Version: 2
Плюсы
- URL не меняется
- «Чистый» REST
- Гибко: можно версионировать отдельные ресурсы
Минусы
- Нельзя протестировать в браузере
- Сложнее кэшировать
- Менее очевидно для клиентов
Кто использует: GitHub (дополнительно к URL), Azure
3. Версия в Query Parameter
GET /api/users?version=2
Плюсы
- Простая реализация
- Легко тестировать
- Можно задать версию по умолчанию
Минусы
- Легко забыть параметр
- Засоряет URL
- Проблемы с кэшированием
Кто использует: Google (некоторые API), Amazon
Рекомендация
Начните с URL-версионирования (/api/v1/). Это самый простой, прозрачный и широко поддерживаемый подход. 90% публичных API его используют.
Реализация URL-версионирования
// Express.js — роутинг по версиям
const express = require('express');
const app = express();
// v1: старый формат ответа
const v1Router = express.Router();
v1Router.get('/users/:id', (req, res) => {
res.json({
id: 42,
name: 'Иван Петров',
// v1: полное имя в одном поле
created: '2026-01-15'
});
});
// v2: новый формат
const v2Router = express.Router();
v2Router.get('/users/:id', (req, res) => {
res.json({
id: 42,
first_name: 'Иван',
last_name: 'Петров',
// v2: имя разделено на два поля
created_at: '2026-01-15T10:30:00Z'
// v2: ISO 8601 вместо простой даты
});
});
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
<?php
// Laravel — группировка роутов по версиям
// routes/api.php
Route::prefix('v1')->group(function () {
Route::get('/users/{id}', [UserV1Controller::class, 'show']);
Route::post('/users', [UserV1Controller::class, 'store']);
});
Route::prefix('v2')->group(function () {
Route::get('/users/{id}', [UserV2Controller::class, 'show']);
Route::post('/users', [UserV2Controller::class, 'store']);
});
// Общие роуты, не зависящие от версии
Route::get('/health', [HealthController::class, 'check']);
Паттерны безопасной эволюции
Как изменять API без новой версии:
1. Additive Changes (только добавление)
Добавляйте новые поля, никогда не удаляйте старые:
// Было (v1)
{
"id": 42,
"name": "Иван Петров",
"created": "2026-01-15"
}
// Стало (по-прежнему v1!)
{
"id": 42,
"name": "Иван Петров",
"first_name": "Иван",
"last_name": "Петров",
"created": "2026-01-15",
"created_at": "2026-01-15T10:30:00Z"
}
Старые клиенты продолжают использовать name и created. Новые клиенты — first_name/last_name и created_at.
2. Параллельные поля (Dual Writing)
При переименовании — поддерживайте оба имени одновременно:
// Сервер возвращает оба поля
app.get('/api/v1/users/:id', (req, res) => {
const user = getUserById(req.params.id);
res.json({
...user,
// Старое имя (для обратной совместимости)
userName: user.username,
// Новое имя
username: user.username,
});
});
// На стороне приёма — принимайте оба варианта
app.post('/api/v1/users', (req, res) => {
const username = req.body.username || req.body.userName;
// ...
});
3. Expand-Collapse (расширение по запросу)
Новые данные доступны через query-параметры, не меняя базовый ответ:
# Базовый ответ (обратно совместим)
GET /api/v1/orders/123
{
"id": 123,
"status": "shipped",
"total": 2500
}
# Расширенный ответ (по запросу)
GET /api/v1/orders/123?expand=items,customer
{
"id": 123,
"status": "shipped",
"total": 2500,
"items": [{"product": "Widget", "qty": 5}],
"customer": {"name": "Иван", "email": "ivan@test.ru"}
}
4. Feature Flags (флаги функций)
Включайте новое поведение через заголовок или параметр:
# Старое поведение (по умолчанию)
GET /api/v1/search?q=widget
# Новый алгоритм (opt-in)
GET /api/v1/search?q=widget
X-Use-New-Search: true
Клиенты мигрируют на новое поведение постепенно, когда будут готовы.
Deprecation: правильный жизненный цикл
Когда старую версию пора отключать — делайте это плавно и предсказуемо:
Выпускайте новую версию. Старая (v1) продолжает работать. Документация обновлена.
Добавьте заголовки Deprecation и Sunset в ответы v1. Уведомите клиентов по email.
Обе версии работают. Мониторьте трафик v1. Помогайте клиентам мигрировать.
Повторное уведомление. Возвращайте Warning-заголовок с каждым ответом v1.
v1 возвращает 410 Gone с ссылкой на v2 и инструкцией миграции.
HTTP-заголовки deprecation
HTTP/1.1 200 OK
Content-Type: application/json
Deprecation: true
Sunset: Sat, 01 Nov 2026 00:00:00 GMT
Link: <https://api.example.com/v2/users>; rel="successor-version"
Warning: 299 - "This API version is deprecated. Migrate to v2 by Nov 2026."
Реализация Deprecation Middleware
// Express.js middleware
function deprecationMiddleware(sunsetDate, successorUrl) {
return (req, res, next) => {
res.set('Deprecation', 'true');
res.set('Sunset', sunsetDate);
res.set('Link', `<${successorUrl}>; rel="successor-version"`);
res.set('Warning', `299 - "Deprecated. Migrate to ${successorUrl} by ${sunsetDate}"`);
next();
};
}
// Применяем к v1
app.use('/api/v1',
deprecationMiddleware(
'Sat, 01 Nov 2026 00:00:00 GMT',
'https://api.example.com/api/v2'
),
v1Router
);
<?php
// Laravel middleware
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class DeprecationMiddleware
{
public function handle(Request $request, Closure $next, string $sunset, string $successor)
{
$response = $next($request);
$response->headers->set('Deprecation', 'true');
$response->headers->set('Sunset', $sunset);
$response->headers->set('Link', "<{$successor}>; rel=\"successor-version\"");
$response->headers->set('Warning',
"299 - \"Deprecated. Migrate to {$successor} by {$sunset}\""
);
return $response;
}
}
// routes/api.php
Route::prefix('v1')
->middleware('deprecation:Sat\, 01 Nov 2026 00:00:00 GMT,https://api.example.com/api/v2')
->group(function () {
Route::get('/users/{id}', [UserV1Controller::class, 'show']);
});
Ответ после Sunset-даты
HTTP/1.1 410 Gone
Content-Type: application/json
{
"error": {
"code": "VERSION_SUNSET",
"message": "API v1 was deprecated on Nov 1, 2026 and is no longer available.",
"migration_guide": "https://docs.example.com/migration/v1-to-v2",
"successor": "https://api.example.com/api/v2"
}
}
Миграция клиентов
Помогите клиентам мигрировать безболезненно:
Migration Guide (руководство по миграции)
Каждая новая версия должна сопровождаться документом с изменениями:
# Миграция v1 → v2
## Breaking Changes
### Поле `name` заменено на `first_name` + `last_name`
**v1:**
```json
{"name": "Иван Петров"}
```
**v2:**
```json
{"first_name": "Иван", "last_name": "Петров"}
```
**Как мигрировать:** Замените обращения к `response.name`
на `response.first_name + " " + response.last_name`.
### Формат даты изменён на ISO 8601
**v1:** `"created": "2026-01-15"`
**v2:** `"created_at": "2026-01-15T10:30:00Z"`
**Как мигрировать:** Обновите парсер даты на ISO 8601.
Changelog — лог изменений
Ведите публичный changelog, чтобы клиенты знали обо всех изменениях:
{
"changelog": [
{
"version": "2.3.0",
"date": "2026-02-20",
"changes": [
{
"type": "added",
"description": "New field 'avatar_url' in user response"
},
{
"type": "deprecated",
"description": "Field 'photo' is deprecated, use 'avatar_url' instead",
"sunset": "2026-08-01"
}
]
}
]
}
Тестирование совместимости
Автоматические тесты — единственный надёжный способ проверить, что ничего не сломалось:
Contract Testing (контрактные тесты)
Тесты, которые проверяют, что API всё ещё соответствует обещанному контракту:
// Jest: контрактные тесты для v1
describe('API v1 Contract', () => {
test('GET /api/v1/users/:id returns expected shape', async () => {
const res = await fetch('/api/v1/users/42');
const data = await res.json();
// Поля, которые ОБЯЗАНЫ быть в ответе (контракт)
expect(data).toHaveProperty('id');
expect(data).toHaveProperty('name');
expect(data).toHaveProperty('email');
expect(data).toHaveProperty('created');
// Типы данных
expect(typeof data.id).toBe('number');
expect(typeof data.name).toBe('string');
expect(typeof data.email).toBe('string');
});
test('POST /api/v1/users still accepts old format', async () => {
const res = await fetch('/api/v1/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Тест Тестов',
email: 'test@test.ru'
})
});
expect(res.status).toBe(201);
});
test('Error format unchanged', async () => {
const res = await fetch('/api/v1/users/nonexistent');
const data = await res.json();
expect(res.status).toBe(404);
expect(data).toHaveProperty('error');
expect(data.error).toHaveProperty('code');
expect(data.error).toHaveProperty('message');
});
});
Snapshot Testing (снимки ответов)
# Python: pytest + snapshot testing
import pytest
import requests
import json
SNAPSHOT_DIR = 'tests/snapshots'
def test_users_response_shape():
"""Проверяем, что структура ответа не изменилась."""
response = requests.get('http://localhost:8000/api/v1/users/42')
data = response.json()
# Извлекаем только ключи (значения меняются)
shape = extract_shape(data)
snapshot_path = f'{SNAPSHOT_DIR}/users_show.json'
with open(snapshot_path) as f:
expected_shape = json.load(f)
assert shape == expected_shape, (
f"Response shape changed! "
f"Expected keys: {expected_shape}, Got: {shape}"
)
def extract_shape(obj, prefix=''):
"""Рекурсивно извлекает структуру JSON."""
shape = {}
for key, value in obj.items():
full_key = f'{prefix}.{key}' if prefix else key
if isinstance(value, dict):
shape[full_key] = extract_shape(value, full_key)
else:
shape[full_key] = type(value).__name__
return shape
OpenAPI diff
Сравнивайте OpenAPI спецификации между версиями автоматически:
# oasdiff — инструмент для сравнения OpenAPI-спецификаций
# Установка
go install github.com/tufin/oasdiff@latest
# Поиск breaking changes
oasdiff breaking openapi-v1.yaml openapi-v2.yaml
# Полный diff
oasdiff diff openapi-v1.yaml openapi-v2.yaml
# В CI/CD pipeline — выход с ошибкой при breaking changes
oasdiff breaking openapi-v1.yaml openapi-v2.yaml --fail-on ERR
Чек-лист перед релизом
✅ Чек-лист обратной совместимости
| # | Проверка | Тип |
|---|---|---|
| 1 | Не удалены существующие поля из ответа | BREAKING |
| 2 | Не переименованы существующие поля | BREAKING |
| 3 | Не изменены типы данных существующих полей | BREAKING |
| 4 | Не добавлены новые обязательные параметры | BREAKING |
| 5 | Не изменены URL эндпоинтов | BREAKING |
| 6 | Не изменён формат ошибок | BREAKING |
| 7 | Новые поля добавлены как необязательные | SAFE |
| 8 | Контрактные тесты проходят | Автотест |
| 9 | OpenAPI diff не показывает breaking changes | Автотест |
| 10 | Deprecated поля помечены в документации | Документация |
| 11 | Changelog обновлён | Документация |
| 12 | Migration guide написан (если новая версия) | Документация |
Типичные ошибки
- «У нас мало клиентов, можно сломать» — завтра их будет 100, и привычка к breaking changes останется
- Отключение v1 без предупреждения — клиенты узнают из ошибок в продакшене
- Версионирование каждого мелкого изменения — v47 через год. Версионируйте только при breaking changes
- Поддержка 5+ версий одновременно — поддержка 2-3 максимум. Остальные — sunset
FAQ
❓ Что такое обратная совместимость API?
Ответ: Свойство API, при котором обновление не ломает существующих клиентов. Клиент, написанный для v1, продолжает работать после выпуска v2. Нарушение обратной совместимости (breaking change) приводит к ошибкам на стороне клиентов.
❓ Какой способ версионирования API лучше?
Ответ: Три подхода: URL-путь (/api/v1/), заголовок (Accept: ...v2+json), query-параметр (?version=2). Рекомендация: URL-версионирование — самый простой и наглядный, используется Stripe, GitHub, Google.
❓ Что считается breaking change в API?
Ответ: Удаление/переименование поля, изменение типа данных, новый обязательный параметр, изменение URL или формата ошибок, изменение HTTP-статусов. Не являются breaking: добавление нового поля, нового эндпоинта, нового необязательного параметра.
❓ Как правильно объявить deprecation API?
Ответ: 1) Заголовки Deprecation: true и Sunset: <дата>. 2) Предупреждение в документации. 3) Email клиентам за 3-6 месяцев. 4) Параллельная работа старой и новой версий. 5) Мониторинг использования. 6) Плавное отключение с 410 Gone и ссылкой на migration guide.
Заключение
📝 Ключевые выводы
- Обратная совместимость — обязательное свойство любого публичного API. Сломанный клиент = потерянный клиент.
- Breaking changes — удаление/переименование полей, изменение типов, новые обязательные параметры. Safe changes — добавление новых полей и эндпоинтов.
- URL-версионирование (
/api/v1/) — самый простой и надёжный подход для большинства API. - Additive changes — добавляйте новое, не удаляйте старое. Так можно развивать API без новой версии.
- Deprecation — заголовки
Deprecation+Sunset, уведомление за 3-6 месяцев, migration guide, плавное отключение. - Контрактные тесты + OpenAPI diff — автоматически ловите breaking changes до того, как они попадут в продакшен.
API — это контракт с вашими клиентами. Как и любой контракт, его нельзя менять в одностороннем порядке. Следуйте принципам из этой статьи — и ваши клиенты будут вам благодарны.
🎯 Проектируйте API правильно с первого раза
LightBox API помогает проектировать, документировать и тестировать API с учётом обратной совместимости, версионирования и идемпотентности.
- ✓ Mock API с версионированием
- ✓ Автоматическая Swagger/OpenAPI документация
- ✓ Контрактное тестирование
- ✓ Логирование запросов
- ✓ Бесплатный план для старта
Статья опубликована: 23 февраля 2026
Автор: LightBox API Team