Зачем версионировать
API -- это публичный контракт. Пока у тебя один клиент (мобильное приложение, которое ты сам деплоишь вместе с backend'ом) -- можно менять всё. Как только появился второй клиент, которого ты не контролируешь (web, партнёрский сервис, third-party интеграция), любое ломающее изменение = inc ident.
Версионирование -- это способ одновременно поддерживать старых и новых клиентов, давая командам возможность мигрировать в их собственном темпе.
Ключевые вопросы до дизайна
- Кто клиент? (внутренний -- можно жёстче; внешний -- долгая поддержка)
- Как часто контракт меняется?
- Какой срок жизни старой версии? (3 месяца? год?)
- Публичный API или только внутри компании?
Backward-compatible vs breaking changes
Backward-compatible (без bump версии)
Старые клиенты продолжают работать. Добавление -- да, удаление/изменение -- нет.
Что можно:
- Добавить optional поле в response
- Добавить новый endpoint
- Добавить необязательный query параметр
- Ослабить валидацию (принимать больше форматов)
- Добавить новый enum-value, если клиент обязан игнорировать неизвестные
- Добавить новый HTTP-заголовок
Что нельзя (breaking):
- Удалить поле из response
- Переименовать поле
- Сделать optional поле required
- Изменить тип поля (string -> int)
- Изменить HTTP-статус для существующего сценария
- Убрать endpoint
- Изменить формат даты (
2025-01-01->01/01/2025) - Добавить required поле в request
- Изменить валидацию в сторону строгости
Пример: добавление поля
// v1 response
{"id": "ord_1", "total": 1000}
// Можно добавить без bump (клиент v1 игнорирует неизвестные поля):
{"id": "ord_1", "total": 1000, "currency": "USD", "discount": 50}
Пример: breaking
// Было
{"user": {"name": "Alice"}}
// Переименование name -> full_name = breaking
{"user": {"full_name": "Alice"}}
Это уже требует новую версию, иначе клиент упадёт.
Стратегии версионирования
1. URL path versioning (/v1/orders, /v2/orders)
Самая распространённая в REST. Плюс: тривиально кэшируется, видно в логах, тестируется curl'ом.
GET /v1/orders/123
GET /v2/orders/123
Минус: "каждый endpoint принадлежит версии" -- мешает эволюции (ленимся поднимать всю версию из-за одного поля).
2. Header versioning (API-Version: 2)
GET /orders/123
API-Version: 2
Плюс: URL один, не путает SEO (для публичных API). Минус: хуже кэшируется (нужен Vary: API-Version), хуже тестируется из браузера.
3. Query parameter (/orders/123?v=2)
GET /orders/123?version=2
Плюс: просто. Минус: легко забыть параметр, CDN-кэши по URL путаются.
4. Content negotiation (Accept header)
Самый "RESTful" способ -- версия как часть media type:
GET /orders/123
Accept: application/vnd.company.order.v2+json
Плюс: соответствует REST-идее "resource has representations". Минус: сложнее, разработчики постоянно путают синтаксис, CDN требует Vary: Accept.
Сравнительная таблица
| Стратегия | CDN-friendly | Лёгкость клиента | "RESTful" | Практика |
|---|---|---|---|---|
URL path /v1/ |
отлично | просто | нет | доминирующая в индустрии |
Header API-Version |
с Vary | просто | средне | популярна в stripe-style API |
Query ?v=1 |
с Vary | средне | нет | встречается редко |
| Content negotiation | с Vary | сложно | да | GitHub API, академически чисто |
Практическая рекомендация: /v1/ в URL -- дефолт для большинства случаев. Для внутренних API можно header'ом.
Semver для API
Применяем SemVer (MAJOR.MINOR.PATCH):
- MAJOR -- breaking change (v1 -> v2 в URL)
- MINOR -- backward-compatible добавление (новое поле, новый endpoint)
- PATCH -- исправление без изменения контракта
Только MAJOR отражается в URL/header. MINOR/PATCH остаются внутри одной версии.
v1.0.0 - initial
v1.1.0 - added "discount" field to order response (compat)
v1.1.1 - fixed bug in "total" calculation (compat)
v2.0.0 - renamed "user_id" to "customer_id" (breaking)
Deprecation policy
Версии не живут вечно. Нужна прописанная политика:
- Announce -- минимум 6 месяцев до удаления, email + changelog +
Deprecationheader - Warn -- все response'ы старой версии включают
Deprecation: trueиSunset: <date>headers (RFC 8594) - Metrics -- мониторить процент трафика на deprecated версии
- Sunset -- в день X вернуть 410 Gone
HTTP/1.1 200 OK
Deprecation: true
Sunset: Wed, 31 Dec 2025 23:59:59 GMT
Link: </v2/orders/123>; rel="successor-version"
Публичные API (Stripe, Twilio, GitHub) -- поддерживают старые версии годами. Внутренние -- 3-6 месяцев стандарт.
Реализация: Symfony router
<?php
declare(strict_types=1);
namespace App\Controller\Api;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
/**
* V1 keeps original field names and status semantics.
* No new features land here - only bug fixes.
*/
#[Route('/v1/orders', name: 'api_v1_orders_')]
final class OrdersV1Controller extends AbstractController
{
public function __construct(
private readonly OrderQueryService $queries,
) {}
#[Route('/{id}', name: 'get', methods: ['GET'])]
public function get(string $id): JsonResponse
{
$order = $this->queries->findById($id);
$response = new JsonResponse([
'id' => $order->id,
'user_id' => $order->customerId, // old field name
'total' => $order->totalCents / 100,
]);
// Deprecation headers per RFC 8594
$response->headers->set('Deprecation', 'true');
$response->headers->set('Sunset', 'Wed, 31 Dec 2025 23:59:59 GMT');
$response->headers->set('Link', '</v2/orders/'.$id.'>; rel="successor-version"');
return $response;
}
}
/**
* V2 - current version. Renamed customer_id, added currency,
* returns cents as integer instead of float "total".
*/
#[Route('/v2/orders', name: 'api_v2_orders_')]
final class OrdersV2Controller extends AbstractController
{
public function __construct(
private readonly OrderQueryService $queries,
) {}
#[Route('/{id}', name: 'get', methods: ['GET'])]
public function get(string $id): JsonResponse
{
$order = $this->queries->findById($id);
return new JsonResponse([
'id' => $order->id,
'customer_id' => $order->customerId, // renamed
'total_cents' => $order->totalCents, // cents as integer
'currency' => $order->currency, // new
]);
}
}
Header-based в Symfony (альтернатива)
Если хочется одну URL:
#[Route('/orders/{id}', methods: ['GET'])]
public function get(string $id, Request $req): JsonResponse
{
$version = (int) ($req->headers->get('API-Version') ?? 1);
$order = $this->queries->findById($id);
return match ($version) {
1 => $this->responseV1($order),
2 => $this->responseV2($order),
default => new JsonResponse(
['error' => 'unsupported_version'],
Response::HTTP_NOT_ACCEPTABLE,
),
};
}
Минус: разветвление в одном контроллере быстро становится messy. Лучше делегировать в serializer.
Реализация: Go chi groups
package api
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
)
type OrderV1 struct {
ID string `json:"id"`
UserID string `json:"user_id"` // old name
Total float64 `json:"total"` // float dollars
}
type OrderV2 struct {
ID string `json:"id"`
CustomerID string `json:"customer_id"` // renamed
TotalCents int64 `json:"total_cents"` // integer cents
Currency string `json:"currency"` // new
}
// MountAPI wires versioned routes under /api.
func MountAPI(r chi.Router, svc *OrderService) {
r.Route("/api/v1", func(r chi.Router) {
r.Get("/orders/{id}", func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
order, err := svc.ByID(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.Header().Set("Deprecation", "true")
w.Header().Set("Sunset", "Wed, 31 Dec 2025 23:59:59 GMT")
w.Header().Set("Link", "</api/v2/orders/"+id+">; rel=\"successor-version\"")
json.NewEncoder(w).Encode(OrderV1{
ID: order.ID,
UserID: order.CustomerID,
Total: float64(order.TotalCents) / 100,
})
})
})
r.Route("/api/v2", func(r chi.Router) {
r.Get("/orders/{id}", func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
order, err := svc.ByID(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(OrderV2{
ID: order.ID,
CustomerID: order.CustomerID,
TotalCents: order.TotalCents,
Currency: order.Currency,
})
})
})
}
Middleware для header-based версии
func APIVersion(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
v := r.Header.Get("API-Version")
if v == "" {
v = "2" // default to current
}
ctx := context.WithValue(r.Context(), ctxKeyVersion{}, v)
w.Header().Set("Vary", "API-Version") // critical for CDN caches
next.ServeHTTP(w, r.WithContext(ctx))
})
}
GraphQL: версионирование через deprecation
В GraphQL нет "v2 schema" в практике -- вместо этого поля помечаются @deprecated, новые добавляются рядом.
type Order {
id: ID!
userId: ID! @deprecated(reason: "Use customerId instead. Will be removed on 2025-12-31")
customerId: ID!
total: Float @deprecated(reason: "Use totalCents (integer)")
totalCents: Int!
currency: String!
}
Плюс: клиенты сами выбирают, какие поля запрашивать -- breaking change затрагивает только тех, кто запрашивает deprecated поле. Минус: schema растёт, нужен discipline по удалению.
Стратегия эволюции GraphQL
- Добавить новое поле рядом (
customerId) -- non-breaking - Пометить старое
@deprecatedс датой удаления - Метрики по запросам старого поля (Apollo Studio, Hasura)
- Когда usage около нуля -- удалить
gRPC / Protobuf: field rules
Protobuf разработан под эволюцию. Правила для совместимости:
Что можно (backward + forward compatible)
- Добавлять новые поля с новыми field numbers
- Удалять
optional/singularполя (старые клиенты получат default value) - Менять
singular<->repeatedдля scalar types (с осторожностью)
Что нельзя
- Переиспользовать field number (катастрофа: старые данные декодируются в новое поле)
- Менять тип поля (int32 -> string)
- Переименовывать поля (имя не в wire-формате, но JSON-маппинг ломается)
syntax = "proto3";
package payment.v1;
message Order {
string id = 1;
// Field 2 was "user_id", removed in v1.2. Do not reuse!
reserved 2;
reserved "user_id";
string customer_id = 3; // new in v1.2
int64 total_cents = 4;
string currency = 5;
// Forward-compat: adding this in v1.3 won't break v1.2 clients
optional string promo_code = 6;
}
Версионирование пакета
Для настоящих breaking changes (пересмотр структуры) создаётся новый package:
// v1 package - frozen
package payment.v1;
// v2 package - new contract
package payment.v2;
Сервер реализует оба, клиенты постепенно мигрируют. По сути -- тот же URL path versioning, но для gRPC.
Поддержка N версий одновременно
На сколько версий назад поддерживать?
- N (текущая) -- полная
- N-1 -- полная, если возможна
- N-2 -- только критичные security fixes
Большее -- технический долг растёт нелинейно. Разделяйте версии тонким слоем (presenter / serializer), а не копированием всей бизнес-логики.
[Controller v1] \
-> [one OrderService] -> [DB]
[Controller v2] /
Правильно: контроллеры/serializer'ы разные, domain logic один. Неправильно: полный fork кода под каждую версию.
Типичные ошибки
- Нет версии вообще -- первая же эволюция ломает всех
- Bump MAJOR на мелочь -- "добавил поле -> v2". Лишняя работа миграции клиентов
- "Версию добавим потом" -- потом это превращается в
/v1/,/api/,/api/v1/,/new-api/одновременно - Нет deprecation header -- клиенты не знают что пора мигрировать
- Удалили версию без метрик -- забыли что интеграция партнёра всё ещё ей пользуется
- Версия в URL + версия в header -- непонятно что приоритетнее
- Копи-паст контроллеров -- изменение бизнес-логики надо делать в N местах
Выводы
Версионирование API -- это обязательный пункт дизайна с первого дня. Практичный дефолт: /v1/ в URL path, Semver (только MAJOR виден), добавление полей -- MINOR без новой версии, переименование/удаление -- новая версия. Поддержка 1-2 версий одновременно, deprecation headers по RFC 8594, метрики по версиям. Для GraphQL -- @deprecated и эволюция schema; для gRPC -- дисциплина с field numbers и reserved. Главный принцип: добавлять безопасно, удалять медленно, всегда говорить клиентам заранее.