Опирается на правила: R-VER-1..6 и R-VER-X1..X4раздел Версионирование.

Важно знать

  • Версия в URL-пути: /api/v1/orders. Формат — v + целое число.
  • Префикс /api обязателен для всех бизнес-эндпоинтов.
  • Новая версия только при breaking change. Non-breaking — в текущей версии.
  • Клиент обязан игнорировать неизвестные поля и enum-значения в ответе.
  • chi монтирует версии через r.Route("/api/v1", ...) и r.Route("/api/v2", ...).
  • Служебные (/health, /metrics) — вне /api, без версии.
  • Минорная версия (v1.2) или дата (/api/2024) — запрещены.
  • Версия в query (?version=1) — запрещена.

Базовая структура роутера

R-VER-1..3:

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)
})

Параллельная поддержка v1 и v2

R-VER-4: breaking change → новая версия; v1 продолжает работать.

r.Route("/api/v1", func(r chi.Router) {
    r.Route("/orders", ordersRouterV1)
})

r.Route("/api/v2", func(r chi.Router) {
    r.Route("/orders", ordersRouterV2)
})

Обе версии могут разделять один слой use-cases; отличаются только DTO и маппинг:

func ordersRouterV1(r chi.Router) {
    r.Get("/", listOrdersV1)
    r.Post("/", createOrderV1)
    // ...
}

func ordersRouterV2(r chi.Router) {
    r.Get("/", listOrdersV2)
    r.Post("/", createOrderV2)
    // ...
}

func listOrdersV1(w http.ResponseWriter, r *http.Request) {
    orders := svc.ListOrders(r.Context(), /* params */)
    writeJSON(w, http.StatusOK, toOrderListResponseV1(orders))
}

func listOrdersV2(w http.ResponseWriter, r *http.Request) {
    orders := svc.ListOrders(r.Context(), /* params */)
    writeJSON(w, http.StatusOK, toOrderListResponseV2(orders))
}

Таблица breaking vs non-breaking

Breaking — требует v2

ИзменениеПример
Удаление endpointубрали DELETE /orders/{id}
Удаление/переименование поляcustomerIdclientId
Удаление/переименование query-параметра?status=?orderStatus=
Изменение типа поляtotal: stringtotal: int64
Удаление значения из enumубрали OrderStatus.DRAFT
Изменение HTTP-методаPOST /ordersPUT /orders
Добавление обязательного поля в запросеrequired deliverySlot
Ужесточение валидацииmaxLength: 200maxLength: 50

Non-breaking — в текущей версии

ИзменениеПример
Необязательное новое поле в ответедобавили metadata в OrderResponse
Новое значение enumOrderStatus.RESERVED
Новый endpointPOST /orders/{id}/duplicate
Необязательный новый query-параметр?channel=web
Ослабление валидацииmaxLength: 50maxLength: 200
Новый error codeORDER_LIMIT_EXCEEDED

R-VER-X4: новую версию не создаём для добавления optional поля в ответ.

Клиент и forward compatibility

R-VER-5: клиент обязан игнорировать неизвестные поля.

В Go стандартный encoding/json игнорирует неизвестные поля по умолчанию — это правильный дефолт:

type OrderResponse struct {
    OrderID string `json:"orderId"`
    Status  string `json:"status"`
    Total   int64  `json:"total"`
    // при добавлении нового поля на сервере клиентский код не сломается
}

var resp OrderResponse
json.Unmarshal(body, &resp)  // неизвестные поля игнорируются

Не используй DisallowUnknownFields() в клиентских десериализаторах — это нарушает forward compatibility.

Для enum — обрабатывай неизвестные значения как 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  // игнорируем неизвестное
    }
}

Deprecation v1 после выхода v2

Когда v2 вышел в production, v1 помечается устаревшим через middleware (R-DEP-*):

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)
})

Подробнее о deprecation — go/rate-limiting-files-deprecation.md.

Что запрещено

АнтипаттернПравилоЧто взамен
/api/v1.2/ordersR-VER-X1/api/v2/orders
/api/2024/ordersR-VER-X1/api/v1/orders
/orders?version=2R-VER-X2/api/v2/orders
/orders без /api и версииR-VER-X3/api/v1/orders
/v1/orders без /apiR-VER-X3/api/v1/orders
Новая версия для optional поляR-VER-X4добавить в текущую
DisallowUnknownFields() в клиентеR-VER-5игнорировать unknown

Куда дальше

  • go/url-and-resources.md — структура маршрутов chi.
  • go/rate-limiting-files-deprecation.md — Sunset + deprecation headers.
  • go/openapi-and-antipatterns.md — operationId, tags в спеке.
  • go/errors.md — ErrorCode enum расширение как non-breaking.