
Полный переход на «v2» API или «новые» события — это месяцы согласований, параллельная поддержка старых клиентов, повышенные расходы и риски, что «что-то забудем» и сломаем партнёров. Бизнесу это бьёт по трём метрикам:
Эволюция схем без глобального «v2» — это набор правил и инструментов, позволяющий выпускать изменения маленькими шагами, не ломая существующих клиентов и потребителей событий.
Простой принцип: «добавляй — не ломай; удаляй — только через деактивацию и этапность; меняй типы и смысл — исключительно с миграцией и оговорёнными адаптерами».
Для REST и JSON даёт результат дисциплина и документация. Базовые правила:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://api.example.com/schemas/customer.json",
"title": "Customer",
"type": "object",
"additionalProperties": true,
"properties": {
"id": { "type": "string", "pattern": "^[0-9a-f-]{36}$" },
"email": { "type": "string", "format": "email" },
"name": { "type": "string", "minLength": 1 },
"marketingConsent": { "type": "boolean", "default": false },
"tags": { "type": "array", "items": { "type": "string" }, "default": [] }
},
"required": ["id", "email"]
}
Здесь допускаются неизвестные поля (additionalProperties: true), новые поля добавляются необязательными. Клиенты, не знающие про marketingConsent и tags, не сломаются.
openapi: 3.0.3
info:
title: Customers API
version: 1.12.0
paths:
/customers/{id}:
get:
summary: Получить профиль клиента
parameters:
- name: id
in: path
required: true
schema: { type: string }
responses:
'200':
description: OK
headers:
Deprecation:
description: |-
true — ресурс или поле устаревает. Клиенту стоит перейти на альтернативы.
schema: { type: string }
Sunset:
description: |-
Дата, когда устаревший аспект перестанет работать.
schema: { type: string, format: date-time }
content:
application/json:
schema:
$ref: '#/components/schemas/Customer'
components:
schemas:
Customer:
type: object
additionalProperties: true
required: [id, email]
properties:
id: { type: string }
email: { type: string, format: email }
name:
type: string
deprecated: true
description: Используйте поле fullName. Будет удалено после 2026-06-01.
fullName: { type: string }
Сервер может выставлять Deprecation: true и Sunset: 2026-06-01T00:00
, а в схемах отметить поле как deprecated.Двоичные форматы с описанной схемой из коробки поддерживают эволюцию: старые потребители пропускают неизвестные поля, новые — могут работать без них.
syntax = "proto3";
package customer.v1;
import "google/protobuf/timestamp.proto";
message Customer {
string id = 1; // нельзя менять номер
string email = 2; // тип и смысл неизменны
// name удаляем — резервируем
reserved 3; // номер 3 больше не используем
reserved "name"; // и имя тоже
// Новое поле вместо name
optional string full_name = 4; // безопасное добавление
// Было: enum Tier { BASIC = 0; PRO = 1; }
enum Tier { BASIC = 0; PRO = 1; ENTERPRISE = 2; } // добавили значение 2
Tier tier = 5;
// Безопасно добавить время обновления
google.protobuf.Timestamp updated_at = 6;
// Вариативные контакты — расширяем через oneof
oneof contact {
string phone = 7;
string telegram = 8; // безопасно добавлено позже
}
}
Если убрать поле name, мы резервируем тег 3 и имя, чтобы никто случайно не переиспользовал. Добавляем full_name с новым тегом — старые потребители его игнорируют, новые — используют.
Buf помогает ловить ломающие изменения в CI:
# buf.yaml
version: v1
breaking:
use:
- FILE
lint:
use:
- DEFAULT
# Инициализация
buf mod init
# Линтинг стиля и правил
buf lint
# Проверка на разрывы относительно main
buf breaking --against "https://github.com/org/repo.git#branch=main"
Avro разрешает добавлять поля с default, удалять необязательные, менять порядок полей. Важно вести реестр схем (Schema Registry) и включить режим совместимости, например BACKWARD или FULL.
Ключевые правила:
События живут дольше внутренних API, их чаще читают разные системы. Безопасный подход:
{
"type": "customer.updated",
"version": 3,
"id": "c0a801b2-8c5f-4d2e-8a2e-1f6a2c9a8c31",
"occurred_at": "2026-01-24T12:34:56Z",
"data": {
"id": "7a7c7d6a-7d1a-4d3c-bd26-a6dd2e7b7a21",
"email": "user@example.com",
"fullName": "Иван Петров",
"tier": "PRO",
"tags": ["beta", "referral"]
}
}
Потребитель, не знающий fullName и tags, должен продолжить работу: парсить знакомые поля и игнорировать лишнее.
Для совместимости по обе стороны иногда полезны преобразователи:
Преобразователи можно держать на стороне подписчиков или в шине (если она это поддерживает), но лучше — в библиотеке контракта, общей для продюсеров и потребителей.
Пример минимального теста на PactJS для REST:
// package.json должен содержать зависимости @pact-foundation/pact
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import fetch from 'node-fetch';
const provider = new PactV3({ consumer: 'web-app', provider: 'customers-api' });
provider
.addInteraction({
states: [{ description: 'customer exists' }],
uponReceiving: 'get customer',
withRequest: { method: 'GET', path: '/customers/123' },
willRespondWith: {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: '123',
email: 'user@example.com',
// Разрешаем дополнительные поля, чтобы не ломаться при расширениях
name: MatchersV3.regex('John', /.+/)
}
}
})
.executeTest(async mock => {
const res = await fetch(`${mock.url}/customers/123`);
const body = await res.json();
if (!body.id || !body.email) throw new Error('missing required fields');
});
Тест допускает дополнительные поля и проверяет лишь необходимые — это залог устойчивости при расширениях.
День 1–2:
День 3–5:
День 6–8:
День 9–11:
День 12–14:
Эти практики позволяют выпускать ценность каждую неделю, а не ждать «идеальную v2». В результате снижаются инциденты, ускоряется time‑to‑market и уменьшаются расходы на поддержку интеграций.