Skip to Content
ConventionsAPI versioning

Версионирование 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.
  • Версия в имени 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 — v1, остаётся kazmaps.review.review.v2 — новый

Шаг 2. Dual-publish

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

Шаг 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. Планируется вынести в отдельный 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 без формальной версионной схемы. Версионируй только то, что действительно потребляется извне.

См. также

  • ../how-to/add-http-endpoint — шаблон нового endpoint’а, куда встраивается версионный префикс.
  • events — envelope, Schema-Version, outbox-dual publish.
  • ../how-to/add-kafka-event — добавить новое событие, с учётом версии с самого начала.
  • ../event-catalog — реестр событий и consumer’ов.
Last updated on