Версионирование API и событий¶
Правила эволюции публичных контрактов: REST endpoint'ов и Kafka-событий. Цель — предсказуемо выпускать изменения, не ломая клиентов и consumer'ов.
Содержание¶
- Зачем версионируем
- REST: URL-versioning
- Breaking vs non-breaking (REST)
- Как выпускать
/v2 - Kafka: schema versioning
- Non-breaking изменение в существующем event
- Breaking изменение: event v2
- Contract tracking
- Тесты на совместимость
- Anti-patterns
- См. также
Зачем версионируем¶
Breaking change в публичном контракте ломает клиентов. Для REST — мобильные приложения и внешние интеграции, которые нельзя обновить одновременно с бэкендом. Для Kafka — consumer'ы в других сервис-репо, которые релизятся своим циклом. Версионирование даёт предсказуемый коридор эволюции: клиенты знают, когда старый контракт отключится, и успевают мигрировать.
REST: URL-versioning¶
Единственный способ версионирования REST API — префикс в URL:
Правила:
- 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 | Сменить тип поля (int → string) |
Только в /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'ы в отдельном пакете:
Роутер монтирует оба префикса:
Шаг 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 сервиса. Новые схемы — с суффиксом версии:
Шаг 5. Deprecation v1¶
В тот же PR (или следующий):
- Включи middleware на
/v1, который проставляетDeprecationиSunsetheaders. - Объяви дату 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-случай:
Breaking изменение: event v2¶
Пример: в review.created меняется тип review_id (int64 → строковый
ULID). Это нельзя сделать в том же topic — старые consumer'ы сломаются.
Шаг 1. Новый topic¶
Шаг 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/:
Тест сериализует реальный 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.md— шаблон нового endpoint'а, куда встраивается версионный префикс.events.md— envelope,Schema-Version, outbox-dual publish.../how-to/add-kafka-event.md— добавить новое событие, с учётом версии с самого начала.../event-catalog.md— реестр событий и consumer'ов.