Обратная совместимость API: как обновлять без поломок

← Вернуться к статьям

Введение

Вы выпустили API. У вас 50 клиентов. Пришло время обновить: переименовать поле, изменить формат даты, убрать устаревший эндпоинт. Но каждое такое изменение может сломать все 50 интеграций.

Обратная совместимость — это искусство развивать API, не ломая существующих клиентов. Twitter, Stripe, Google — все крупные API-провайдеры годами поддерживают старые версии, одновременно выпуская новые.

В этой статье разберём:

📋 Содержание

Что такое обратная совместимость

Обратная совместимость (backward compatibility) — свойство API, при котором обновление не ломает существующих клиентов. Клиент, написанный для v1, продолжает работать после обновления сервера.

Принцип Робастности (Postel's Law)

«Будь либерален к тому, что принимаешь, и консервативен к тому, что отправляешь»

Это основа обратной совместимости: сервер должен принимать любой валидный запрос старого формата и возвращать ответ, который старый клиент сможет обработать.

Почему это так важно:

Breaking vs Non-breaking changes

Понимание разницы — фундамент для безопасных обновлений:

Breaking changes (ломающие изменения)

Любое изменение, которое может вызвать ошибку у существующего клиента:

Изменение Тип Почему ломает
Удаление эндпоинта BREAKING Клиент получит 404
Удаление поля из ответа BREAKING Клиент обращается к несуществующему полю
Переименование поля BREAKING Старое имя больше не существует
Изменение типа поля (stringnumber) 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: правильный жизненный цикл

Когда старую версию пора отключать — делайте это плавно и предсказуемо:

Месяц 0 — Выпуск v2

Выпускайте новую версию. Старая (v1) продолжает работать. Документация обновлена.

Месяц 1 — Объявление deprecation

Добавьте заголовки Deprecation и Sunset в ответы v1. Уведомите клиентов по email.

Месяцы 2-5 — Период миграции

Обе версии работают. Мониторьте трафик v1. Помогайте клиентам мигрировать.

Месяц 6 — Напоминание

Повторное уведомление. Возвращайте Warning-заголовок с каждым ответом v1.

Месяц 9-12 — Отключение 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Контрактные тесты проходятАвтотест
9OpenAPI diff не показывает breaking changesАвтотест
10Deprecated поля помечены в документацииДокументация
11Changelog обновлёнДокументация
12Migration guide написан (если новая версия)Документация

Типичные ошибки

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.

Заключение

📝 Ключевые выводы

  1. Обратная совместимость — обязательное свойство любого публичного API. Сломанный клиент = потерянный клиент.
  2. Breaking changes — удаление/переименование полей, изменение типов, новые обязательные параметры. Safe changes — добавление новых полей и эндпоинтов.
  3. URL-версионирование (/api/v1/) — самый простой и надёжный подход для большинства API.
  4. Additive changes — добавляйте новое, не удаляйте старое. Так можно развивать API без новой версии.
  5. Deprecation — заголовки Deprecation + Sunset, уведомление за 3-6 месяцев, migration guide, плавное отключение.
  6. Контрактные тесты + OpenAPI diff — автоматически ловите breaking changes до того, как они попадут в продакшен.

API — это контракт с вашими клиентами. Как и любой контракт, его нельзя менять в одностороннем порядке. Следуйте принципам из этой статьи — и ваши клиенты будут вам благодарны.

🎯 Проектируйте API правильно с первого раза

LightBox API помогает проектировать, документировать и тестировать API с учётом обратной совместимости, версионирования и идемпотентности.

Попробовать бесплатно →

Статья опубликована: 23 февраля 2026
Автор: LightBox API Team