Перейти к содержанию

Версионирование API и событий

Правила эволюции публичных контрактов: REST endpoint'ов и Kafka-событий. Цель — предсказуемо выпускать изменения, не ломая клиентов и consumer'ов.

Содержание

Зачем версионируем

Breaking change в публичном контракте ломает клиентов. Для REST — мобильные приложения и внешние интеграции, которые нельзя обновить одновременно с бэкендом. Для Kafka — consumer'ы в других сервис-репо, которые релизятся своим циклом. Версионирование даёт предсказуемый коридор эволюции: клиенты знают, когда старый контракт отключится, и успевают мигрировать.

REST: URL-versioning

Единственный способ версионирования REST API — префикс в URL:

/v1/reviews
/v2/reviews

Правила:

  • Major version в path. /v1, /v2, /v3. Minor/patch в URL не вставляй — добавляй совместимые изменения в ту же major-версию.
  • Никаких Accept-header'ов с версией (application/vnd.kazmaps.v2+json).
  • Никаких query-параметров с версией (?version=2).
  • Major version = breaking change. Любое совместимое изменение (добавить поле, расширить перечисление) — в ту же версию.
  • Deprecation. /v1 живёт минимум 12 месяцев после выхода /v2. На ответах /v1 после объявления deprecation добавляй:
Deprecation: true
Sunset: Sat, 18 Apr 2026 00:00:00 GMT
Link: </v2/reviews>; rel="successor-version"
  • После Sunset-даты /v1 удаляется одним PR вместе с routing-кодом.

Breaking vs non-breaking (REST)

Тип Пример Куда
Non-breaking Добавить optional поле в response В тот же /v1
Non-breaking Добавить query-параметр со значением по умолчанию В тот же /v1
Non-breaking Новый endpoint (GET /v1/reviews/{id}/reactions) В тот же /v1
Non-breaking Расширить enum новым допустимым значением В тот же /v1
Non-breaking Более мягкая валидация (лимит был 100, стал 200) В тот же /v1
Breaking Удалить поле из response Только в /v2
Breaking Переименовать поле Только в /v2
Breaking Сменить тип поля (intstring) Только в /v2
Breaking Сделать optional-поле required в request Только в /v2
Breaking Сменить HTTP-статус на другой код Только в /v2
Breaking Более жёсткая валидация (лимит был 200, стал 100) Только в /v2
Breaking Поменять семантику поля при том же имени Только в /v2

Если сомневаешься — считай изменение breaking. Дешевле выпустить /v2, чем тихо сломать мобильное приложение.

Как выпускать /v2

Стандартный сценарий: в репозитории сервиса добавляется параллельная ветка handler'ов/DTO, service-слой остаётся общим.

Шаг 1. Handler v2

Новые handler'ы в отдельном пакете:

internal/handler/v1/reviews.go   — существующий
internal/handler/v2/reviews.go   — новый

Роутер монтирует оба префикса:

r.Route("/v1/reviews", v1.Reviews(d).Routes)
r.Route("/v2/reviews", v2.Reviews(d).Routes)

Шаг 2. DTO v2

Новые DTO в отдельном подпакете:

// pkg/dto/v2/reviews.go
package v2

type ReviewResponse struct {
    ID       int64   `json:"id"`
    PlaceID  int64   `json:"place_id"`
    Rating   int16   `json:"rating"`
    Author   Author  `json:"author"`
    // ...
}

Шаг 3. Общий service-слой

Service-методы не дублируются. Handler v1 и handler v2 вызывают одни и те же service.ReviewService, но маппят в разные DTO. Если бизнес-логика поменялась (например, изменились правила агрегации) — это новый service-метод, а не «метод с флагом версии».

Шаг 4. OpenAPI

Обнови api/openapi.yaml сервиса. Новые схемы — с суффиксом версии:

components:
  schemas:
    ReviewResponseV1: { ... }
    ReviewResponseV2: { ... }

Шаг 5. Deprecation v1

В тот же PR (или следующий):

  • Включи middleware на /v1, который проставляет Deprecation и Sunset headers.
  • Объяви дату sunset в release notes сервис-репо.
  • Сообщи владельцам клиентских приложений.

Kafka: schema versioning

Для событий — два параллельных механизма:

  • Schema-Version в envelope — monotonic int, растёт при любом изменении формы payload'а. Реф: events.md.
  • Версия в имени topic'а (.v2-суффикс) — только при breaking изменении.

Базовое правило:

  • Non-breaking change (добавление optional поля, расширение enum) → bump Schema-Version, тот же topic.
  • Breaking change (удаление/переименование/смена типа) → новый topic kazmaps.<service>.<entity>.<action>.v2.

Consumer'ы работают по принципу Postel's law: будь строг к тому, что публикуешь, и толерантен к тому, что принимаешь. Лишние поля в payload'е игнорируются без ошибки — это обеспечивает forward-compatibility для старых consumer'ов при добавлении новых полей.

Non-breaking изменение в существующем event

Пример: в review.created добавляется опциональное поле language.

Код publisher'а

В структуре payload — указатель для optional:

type ReviewCreatedV2 struct {
    ReviewID int64   `json:"review_id"`
    PlaceID  int64   `json:"place_id"`
    UserID   int64   `json:"user_id"`
    Rating   int16   `json:"rating"`
    Language *string `json:"language,omitempty"`  // новое поле
}

Envelope

Bump версии:

eventmeta.New(ctx, eventmeta.Envelope{
    EventType:     "review.created",
    SchemaVersion: "2",   // было "1"
    Source:        "review",
    Payload:       payload,
})

Consumer'ы

Consumer'ы продолжают работать. Те, кто не знает про language, получают payload, в котором поле либо отсутствует (nil), либо проигнорировано при десериализации в старую структуру.

Consumer, которому поле нужно, обрабатывает nil-случай:

if payload.Language != nil {
    // use *payload.Language
}

Breaking изменение: event v2

Пример: в review.created меняется тип review_id (int64 → строковый ULID). Это нельзя сделать в том же topic — старые consumer'ы сломаются.

Шаг 1. Новый topic

kazmaps.review.review.created        — v1, остаётся
kazmaps.review.review.created.v2     — новый

Шаг 2. Dual-publish

Publisher записывает обе строки в outbox в той же транзакции (v1 + v2). Forwarder публикует обе. См. ../patterns/outbox.md.

Шаг 3. Миграция consumer'ов

Consumer'ы мигрируют по одному в своём репо: переключают subscription на .v2, обновляют handler, релизят. Пока мигрирует хоть один consumer — publisher дублирует.

Шаг 4. Sunset старого topic

После того как все consumer'ы v1 ушли (подтверждается через kafka_consumer_lag по consumer-group'е и через владельцев сервисов), publisher перестаёт дублировать. Через минимум 12 месяцев после объявления sunset старый topic удаляется из Kafka.

Contract tracking

  • OpenAPI spec REST API — в репо сервиса: api/openapi.yaml. Обновляется в том же PR, что и endpoint.
  • Event payload schemas — описаны inline в ../event-catalog.md. Планируется вынести в отдельный contract-репо, но до этого момента единая точка истины — каталог событий handbook'а.
  • Consumer registry — в каталоге событий указаны все consumer'ы каждого topic'а. Перед breaking-change смотри именно туда: это список команд, которых нужно уведомить.

Тесты на совместимость

Golden response-тесты (REST)

Для каждого версионированного endpoint'а — фиксированный JSON в testdata/:

testdata/v1-review-response.json
testdata/v2-review-response.json

Тест сериализует реальный response и сравнивает с golden:

func TestReviewV1_Response_Golden(t *testing.T) {
    got := renderV1(domain.Review{ID: 1, PlaceID: 7, Rating: 5})
    want := testhelp.ReadGolden(t, "v1-review-response.json")
    if diff := cmp.Diff(want, got); diff != "" {
        t.Fatalf("response mismatch:\n%s", diff)
    }
}

Если golden меняется — это предупреждение: либо ты намеренно ломаешь v1 (плохо), либо добавил optional поле (обнови golden в PR).

Golden payload-тесты (Kafka)

Для каждого event-type — зафиксированный JSON-payload. Тот же механизм: сериализуй, сравни с файлом в testdata/events/.

Schema validation (опционально)

Для hot-path contract'ов допустимо подключить JSON Schema и валидировать в тесте, что payload соответствует схеме. Это дороже golden-сравнения, но даёт более явную ошибку при расхождении.

Anti-patterns

  • Удаление поля без v2. Клиенты, которые его читали, ломаются. Помечай поле deprecated в OpenAPI, но физически удаляй только в следующей major.
  • Breaking change в тот же topic. Старые consumer'ы падают на десериализации. Bump Schema-Version не спасает — это только пометка.
  • Bump Schema-Version при breaking. Маскирует проблему: consumer видит «schema 2», десериализует в старую структуру, получает битые данные. Breaking изменение — всегда новый topic.
  • Многолетнее параллельное сопровождение всех версий. Каждая лишняя живущая версия — tech debt: дублирование handler'ов, тестов, схем. Устанавливай sunset и соблюдай его.
  • Версия внутри payload'а ("version": 2). Это передублирует Schema-Version из envelope и приводит к несогласованности.
  • Подмена major через «feature flag». Условная логика «если клиент пришёл с новым header'ом — другой формат ответа» превращает endpoint в два endpoint'а с общим URL. Выноси как /v2.
  • Версионирование internal API без нужды. /internal/* ходит между сервисами одного релизного контура; там достаточно координировать выкатку PR без формальной версионной схемы. Версионируй только то, что действительно потребляется извне.

См. также