Когда API выходит за пределы одной команды — у него появляются клиенты, которые рассчитывают на стабильный контракт. Стоит изменить поле или убрать эндпоинт — чужой код ломается. Версионирование решает эту проблему: старый контракт живёт под /api/v1, новый — под /api/v2.
Куда ставить номер версии
Есть несколько вариантов, где записать версию API: в URL, в заголовке, в query-параметре. Самый простой и очевидный — URL-путь:
/api/v1/orders
/api/v2/orders
Версию видно в браузере, в логах, в curl — без лишних усилий. Формат: буква v и целое число (v1, v2, v3). Минорные варианты вроде v1.2 или даты вроде /api/2024 — не используют: они усложняют маршрутизацию и путают клиентов.
Префикс /api обязателен для всех бизнес-эндпоинтов. Служебные эндпоинты (/health, /metrics) стоят вне /api и без версии — они не являются частью публичного контракта.
Версия в query-параметре (?version=2) тоже не используется: её легко потерять при проксировании и кэшировании.
Базовая структура роутера с chi
В Go с библиотекой chi версии монтируются через r.Route:
r := chi.NewRouter()
r.Get("/health", healthHandler)
r.Get("/metrics", metricsHandler)
r.Route("/api/v1", func(r chi.Router) {
r.Use(authMiddleware, tracingMiddleware)
r.Route("/orders", ordersRouterV1)
r.Route("/products", productsRouterV1)
r.Route("/customers", customersRouterV1)
})
Все middleware (авторизация, трейсинг) подключаются один раз на уровне версии — не дублируются в каждом ресурсе.
Что такое breaking change
Не каждое изменение требует новой версии. Ключевое различие: сломает ли изменение существующий клиентский код?
Изменения, требующие новой версии (breaking):
- Удалить или переименовать эндпоинт
- Удалить или переименовать поле в ответе (
customerId→clientId) - Изменить тип поля (
total: string→total: int64) - Убрать значение из перечисления (
OrderStatus.DRAFTисчезает) - Изменить HTTP-метод эндпоинта
- Сделать новое поле обязательным в запросе
- Ужесточить валидацию (
maxLength: 200→maxLength: 50) - Удалить или переименовать query-параметр
Изменения, которые делают в текущей версии (non-breaking):
- Добавить новое необязательное поле в ответ
- Добавить новый эндпоинт
- Добавить новое значение в перечисление
- Добавить необязательный query-параметр
- Ослабить валидацию (
maxLength: 50→maxLength: 200) - Добавить новый код ошибки
Частая ошибка — создавать v2 ради добавления одного опционального поля. Это лишняя работа: добавить metadata в OrderResponse — non-breaking, просто добавьте поле в v1.
Параллельная поддержка v1 и v2
Когда breaking change всё же нужен — создаётся новая версия. Старая продолжает работать, пока клиенты не мигрируют:
r.Route("/api/v1", func(r chi.Router) {
r.Route("/orders", ordersRouterV1)
})
r.Route("/api/v2", func(r chi.Router) {
r.Route("/orders", ordersRouterV2)
})
Внутри v1 и v2 могут использовать один и тот же слой бизнес-логики — разница только в DTO и преобразованиях:
func listOrdersV1(w http.ResponseWriter, r *http.Request) {
orders := svc.ListOrders(r.Context())
writeJSON(w, http.StatusOK, toOrderListResponseV1(orders))
}
func listOrdersV2(w http.ResponseWriter, r *http.Request) {
orders := svc.ListOrders(r.Context())
writeJSON(w, http.StatusOK, toOrderListResponseV2(orders))
}
Логика svc.ListOrders общая, меняются только функции преобразования результата.
Клиент и добавление полей
Правильно написанный клиент игнорирует незнакомые поля в ответе. Стандартный encoding/json в Go делает это по умолчанию — это верное поведение:
type OrderResponse struct {
OrderID string `json:"orderId"`
Status string `json:"status"`
Total int64 `json:"total"`
// если сервер добавит новое поле — этот код не сломается
}
var resp OrderResponse
json.Unmarshal(body, &resp) // неизвестные поля молча игнорируются
Не стоит использовать DisallowUnknownFields() в клиентском коде: это делает клиента хрупким — любое расширение сервера будет его ломать.
Аналогично для перечислений — неизвестные значения нужно обрабатывать как unknown, а не падать:
type OrderStatus string
const (
StatusNew OrderStatus = "NEW"
StatusConfirmed OrderStatus = "CONFIRMED"
StatusUnknown OrderStatus = ""
)
func parseStatus(s string) OrderStatus {
switch OrderStatus(s) {
case StatusNew, StatusConfirmed:
return OrderStatus(s)
default:
return StatusUnknown // неизвестное значение — не ошибка
}
}
Как объявить версию устаревшей
Когда v2 вышел в продакшн и клиенты могут мигрировать, v1 помечается устаревшим через HTTP-заголовки. Клиенты, которые следят за заголовками, увидят предупреждение задолго до отключения:
func deprecatedMiddleware(sunset, successorURL string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Sunset", sunset)
w.Header().Set("Deprecation", "true")
w.Header().Set("Link", `<`+successorURL+`>; rel="successor-version"`)
next.ServeHTTP(w, r)
})
}
}
r.Route("/api/v1", func(r chi.Router) {
r.Use(deprecatedMiddleware(
"Sat, 01 Jan 2027 00:00:00 GMT",
"https://api.example.com/api/v2",
))
r.Route("/orders", ordersRouterV1)
})
Заголовок Sunset содержит дату отключения. Link с rel="successor-version" указывает на замену.
Коротко
- Версия идёт в URL-путь:
/api/v1/orders. Формат —v+ целое число. - Префикс
/apiобязателен для бизнес-эндпоинтов./healthи/metrics— вне/api, без версии. - Новую версию создают только при breaking change: удаление/переименование поля или эндпоинта, смена типа, ужесточение валидации.
- Добавление необязательного поля, нового эндпоинта, нового значения enum — non-breaking, делается в текущей версии.
- v1 и v2 могут делить один слой бизнес-логики; разница — в DTO и преобразованиях.
encoding/jsonигнорирует неизвестные поля по умолчанию.DisallowUnknownFields()в клиенте — плохая практика.- Устаревшую версию помечают заголовками
Sunset,Deprecation,Link.
Что почитать дальше
- URL и ресурсы — структура маршрутов chi.
- Ошибки RFC 9457 — как расширять коды ошибок без breaking change.
- Rate limiting и deprecation — заголовки
Sunsetподробнее.