Архитектурные паттерны Стили API: REST, GraphQL, gRPC, вебхуки и версионирование
0%

Стили API: REST, GraphQL, gRPC, вебхуки и версионирование

Стили API: REST, GraphQL, gRPC, вебхуки и версионирование

API — это единственная часть системы, которую вы не можете отрефакторить в одиночку. Внутренний код принадлежит вам: переименовали класс, прогнали тесты, поехали. Опубликованный контракт принадлежит всем, кто его вызывает, и стоимость его изменения растёт вместе с числом клиентов — линейно по количеству интеграций и почти неограниченно по времени, если среди клиентов есть мобильные приложения, которые пользователь обновит когда захочет (или никогда).

Отсюда два практических следствия, которые задают структуру этой статьи. Первое: выбор стиля API — это выбор формы связанности между вашей системой и её потребителями, и менять его потом дорого. Второе: умение эволюционировать контракт важнее умения спроектировать его идеально с первого раза, потому что идеально не получится ни у кого.

Предыдущие статьи трека дали контекст: микросервисы объяснили, почему сетевые контракты вообще появляются, а событийная архитектура — как выглядит асинхронная альтернатива вызову. Здесь мы разбираем синхронную границу и push-уведомления наружу.

1. Оси выбора: чем стили реально отличаются

Спор «REST против gRPC против GraphQL» почти всегда бесплоден, потому что стили сравнивают по популярности, а не по свойствам. Полезнее разложить их по четырём независимым осям.

Ось 1. Кто инициирует. Запрос-ответ (клиент дёргает сервер) или push (сервер уведомляет клиента). Вебхуки, SSE и WebSocket живут на второй половине оси, REST/gRPC/GraphQL — на первой.

Ось 2. Что является единицей контракта. Ресурс (существительное: «заказ», «пользователь»), процедура (глагол: «перевести деньги»), или граф с выборкой (клиент сам описывает нужную форму). Это самое глубокое различие, и оно определяет, куда попадёт бизнес-логика.

Ось 3. Кто владеет формой ответа. Сервер (REST, gRPC) или клиент (GraphQL, OData, частично — ?fields= в REST). Владение формой на клиенте убирает лишние раунд-трипы, но переносит на сервер задачу произвольных запросов, включая их стоимость и защиту.

Ось 4. Насколько строг контракт. От «JSON как получится» через OpenAPI-описание к кодогенерации из .proto, где несовместимая схема не соберётся. Строгость — это не всегда хорошо: жёсткая схема отлично работает внутри организации и плохо — на публичной границе, где нельзя заставить всех перегенерировать клиент.

Обратите внимание на нижнюю ветку: BFF (Backend for Frontend) — полноценная альтернатива GraphQL, а не запасной вариант. Если у вас два-три фронтенда и стабильные экраны, эндпоинт на экран решает ту же задачу за десятую часть операционной сложности.

Over-fetching, under-fetching и точный срез данных

2. REST: не «HTTP с JSON», а ресурсная модель

Термин ввёл Рой Филдинг в диссертации 2000 года, и описывал он не формат, а набор ограничений: клиент-сервер, отсутствие состояния сессии на сервере, кэшируемость, единообразный интерфейс, слоистая система. Практическая ценность здесь не в чистоте терминологии, а в том, что каждое ограничение покупает конкретное свойство.

Statelessness покупает масштабирование. Раз запрос самодостаточен, любой инстанс может его обслужить: балансировщик не обязан помнить, куда ходил клиент, а падение инстанса не теряет сессию. Всё, что вы кладёте в память процесса между запросами, — это отказ от этого свойства.

Единообразный интерфейс покупает инфраструктуру бесплатно. Если GET действительно безопасен, а PUT действительно идемпотентен, то CDN, прокси, ретраи в клиентских библиотеках и браузерный кэш работают корректно без вашего участия. Если вы делаете GET /orders/42/cancel, вы отменяете заказ каждый раз, когда прокси решит префетчнуть ссылку.

Семантика методов: таблица, которую стоит помнить наизусть

Метод Безопасен Идемпотентен Кэшируем Типичный ответ
GET да да да 200 + тело, 304 при валидном ETag
HEAD да да да 200 без тела
PUT нет да нет 200/204, 201 при создании
DELETE нет да нет 204, повторный — тоже 204 или 404
POST нет нет почти нет 201 + Location, 202 для асинхронной обработки
PATCH нет зависит нет 200/204

Идемпотентность здесь — свойство эффекта на сервере, а не одинаковости ответа. DELETE идемпотентен, потому что второе удаление не меняет состояние, даже если код ответа отличается. Это ровно то свойство, на которое опираются ретраи: клиент, не получивший ответа, не знает, дошёл ли запрос, и может повторить безопасно только идемпотентную операцию.

Модель зрелости Ричардсона: где остановиться

Леонард Ричардсон предложил четыре уровня:

  • Уровень 0 — один URL, один метод POST, всё различие в теле. Это RPC поверх HTTP; ничего постыдного, но и никаких бонусов от HTTP.
  • Уровень 1 — появились ресурсы: /orders/42, /customers/7.
  • Уровень 2 — заработали глаголы HTTP и коды статусов. Здесь находится 95% промышленных API, и это осознанная точка остановки.
  • Уровень 3 — HATEOAS: ответ содержит ссылки на доступные переходы.

Про уровень 3 стоит быть честным. Идея красива: клиент не хардкодит URL, а идёт по ссылкам, и сервер свободно меняет адресацию. На практике это почти нигде не окупается, потому что клиенты всё равно пишутся под конкретные переходы, а обход по ссылкам добавляет раунд-трипы. Но одна часть идеи работает всегда и стоит копейки: отдавайте в ответе список доступных сейчас действий. Не URL-навигацию ради навигации, а "allowed_actions": ["cancel", "refund"] — тогда UI не дублирует у себя правила предметной области и не показывает кнопку «отменить» на уже отгруженном заказе.

Действия, которые не ложатся на CRUD

Главная боль ресурсного стиля: бизнес полон глаголов. «Отменить заказ», «провести инвентаризацию», «пересчитать бонусы». Три рабочих приёма, в порядке предпочтения:

  1. Овеществить действие в ресурс. Отмена — это не мутация заказа, а сущность: POST /orders/42/cancellations с телом-причиной. Бонус: у вас появляется история отмен, идентификатор для идемпотентности и место для аудита.
  2. Подресурс-состояние. PUT /orders/42/status с телом {"value": "cancelled"} — годится, когда переход тривиален и не имеет собственных атрибутов.
  3. Явный «командный» эндпоинт. POST /orders/42:cancel — так делает Google AIP-136 для custom methods. Прагматично; не стесняйтесь, если первые два варианта натягиваются на глобус.

Чего делать не надо — прятать команду в PATCH общего вида. PATCH /orders/42 с телом {"status": "cancelled"} выглядит невинно, но означает, что вся логика переходов состояния теперь размазана по проверкам «а что именно нам прислали», а клиент имеет формальное право прислать любое поле.

Ошибки: RFC 9457 и почему свой формат — плохая идея

Каждая компания однажды изобретает свой конверт ошибки. Их уже стандартизировали — RFC 9457 Problem Details for HTTP APIs (заменил RFC 7807):

{
  "type": "https://api.example.com/errors/insufficient-funds",
  "title": "Недостаточно средств",
  "status": 409,
  "detail": "На счёте 320.50 RUB, требуется 1200.00 RUB",
  "instance": "/orders/42/payments/9",
  "balance": "320.50",
  "required": "1200.00"
}

Ключевое поле — type: это стабильный машиночитаемый идентификатор, на который клиент может ветвиться. title и detail — для человека, их можно менять и локализовать. Расширяющие поля (balance, required) добавляются свободно. Правило: код HTTP говорит классу проблемы, type — конкретной причине, и никогда не кодируйте ошибку в теле при 200 OK — это ломает ретраи, метрики и все инструменты, которые смотрят на статус.

Пагинация: почему offset ломается на проде

Классический ?page=5&size=20 транслируется в OFFSET 80 LIMIT 20. Две беды. Первая — сложность: чтобы отдать 20 строк со смещением N, база обязана прочитать и отбросить N строк, то есть запрос стоит O(N + limit) и деградирует линейно по глубине. Пятитысячная страница кладёт базу. Вторая — корректность: между запросами данные меняются, и вставка новой строки сдвигает окно так, что элемент показывается дважды или пропускается.

Правильный ответ — keyset (cursor) пагинация: вместо «пропусти N» говорим «дай, что идёт после этого ключа».

-- Курсорная пагинация: O(log N + limit) при индексе (created_at DESC, id DESC).
-- Кортежное сравнение обязательно — иначе строки с одинаковым created_at теряются.
SELECT id, created_at, total
FROM orders
WHERE tenant_id = $1
  AND (created_at, id) < ($2, $3)   -- значения из курсора предыдущей страницы
ORDER BY created_at DESC, id DESC
LIMIT 21;                            -- на одну больше: есть ли следующая страница

Курсор отдавайте клиенту как непрозрачную строку (base64 от кортежа плюс версия формата и, если нужно, подпись). Непрозрачность — не косметика: она даёт право поменять внутреннюю схему сортировки, не ломая клиентов, и не позволяет подобрать чужой курсор.

Ограничение метода честное: произвольного перехода на страницу 137 не будет — только «вперёд/назад». Для бесконечных лент и синхронизации это ровно то, что нужно; для административной таблицы с нумерацией страниц придётся оставить offset и ограничить глубину.

Условные запросы и идемпотентность записи

Два механизма, которые в REST-API чаще всего забывают, а стоят они дёшево.

ETag + If-Match даёт оптимистическую блокировку. Клиент получает ETag: "v7", при записи присылает If-Match: "v7", сервер отвечает 412 Precondition Failed, если версия устарела. Это решает потерянное обновление без единой строчки в бизнес-логике клиента.

Idempotency-Key спасает POST. Клиент генерирует UUID, сервер запоминает пару (ключ → результат) и при повторе возвращает сохранённый ответ вместо второго списания. Так работает Stripe; детальный разбор семантики есть в черновике IETF по Idempotency-Key.

# FastAPI: идемпотентный POST с постоянным хранением результата.
# Критично: запись ключа и бизнес-эффект должны попасть в ОДНУ транзакцию,
# иначе при падении между ними получим либо двойное списание, либо вечный 409.
from fastapi import APIRouter, Header, HTTPException, Response
from sqlalchemy.exc import IntegrityError

router = APIRouter()

@router.post("/payments", status_code=201)
async def create_payment(body: PaymentIn, response: Response,
                         idempotency_key: str | None = Header(default=None)):
    if idempotency_key is None:
        raise HTTPException(400, "Требуется заголовок Idempotency-Key")

    async with db.begin() as tx:                       # одна транзакция на всё
        saved = await tx.fetch_idempotent(idempotency_key)
        if saved is not None:
            # Повтор с тем же ключом, но другим телом — почти всегда баг клиента.
            if saved.request_hash != body.stable_hash():
                raise HTTPException(422, "Idempotency-Key переиспользован с другим телом")
            response.status_code = saved.status
            return saved.response_body                  # тот же ответ, эффект не повторён

        try:
            payment = await tx.charge(body)             # собственно бизнес-эффект
            await tx.store_idempotent(idempotency_key, body.stable_hash(),
                                      status=201, response_body=payment.as_dict())
        except IntegrityError:
            # Гонка: параллельный запрос с тем же ключом уже вставил строку.
            # 409 корректен — клиенту достаточно повторить и получить сохранённый ответ.
            raise HTTPException(409, "Запрос с этим ключом уже выполняется")

    return payment.as_dict()

Три детали, которые отличают рабочую реализацию от учебной: (1) хэш тела, чтобы поймать переиспользование ключа; (2) одна транзакция на эффект и запись ключа; (3) TTL на записи — хранить ключи вечно не нужно, 24–72 часа перекрывают любые разумные ретраи.

3. gRPC: контракт как код

gRPC разворачивает модель на 180°: единицей контракта становится процедура, схема описывается в .proto, из неё генерируются клиент и сервер, а транспорт — HTTP/2 с бинарным protobuf вместо JSON.

syntax = "proto3";
package orders.v1;                       // версия — в имени пакета, это важно

service OrderService {
  rpc GetOrder(GetOrderRequest) returns (Order);
  rpc ListOrders(ListOrdersRequest) returns (ListOrdersResponse);
  // Серверный стриминг: одна подписка — поток обновлений статуса.
  rpc WatchOrder(WatchOrderRequest) returns (stream OrderEvent);
}

message Order {
  string id = 1;
  string customer_id = 2;
  Money total = 3;
  Status status = 4;
  reserved 5;                            // номер удалённого поля НИКОГДА не переиспользуем
  reserved "legacy_discount";

  enum Status {
    STATUS_UNSPECIFIED = 0;              // нулевое значение всегда «не задано»
    STATUS_PENDING = 1;
    STATUS_PAID = 2;
    STATUS_CANCELLED = 3;
  }
}

Что даёт эта схема на практике:

  • Дешёвая сериализация. Полевые номера вместо имён, varint-кодирование, отсутствие парсинга текста. На типичном сообщении это 3–10× по размеру относительно JSON и заметно меньше CPU — на трафике «сервис-сервис» в десятки тысяч RPS разница видна в счёте за железо.
  • Мультиплексирование HTTP/2. Много одновременных вызовов в одном TCP-соединении без head-of-line blocking на уровне HTTP, без пула из сотни соединений.
  • Четыре режима вызова: унарный, серверный стриминг, клиентский стриминг, двунаправленный. Стриминг здесь — не экзотика, а штатный способ отдать длинную выборку или подписку.
  • Правила совместимости, встроенные в формат. Добавление поля с новым номером совместимо в обе стороны; удаление поля требует reserved; смена типа или номера — ломающее изменение. Это дисциплина, которой в JSON-API приходится добиваться ревью и договорённостями.

Дедлайны — главное, что стоит перенести из gRPC в любой API

В gRPC дедлайн — часть вызова и распространяется по цепочке: если у входящего запроса осталось 80 мс, вызов вниз получит не больше 80 мс. Это лечит классическую патологию распределённых систем, когда верхний уровень давно отдал таймаут, а сервисы под ним продолжают жечь ресурсы на никому не нужный ответ.

// Go-клиент gRPC: дедлайн наследуется из контекста входящего запроса.
func (h *Handler) Checkout(ctx context.Context, req *pb.CheckoutRequest) (*pb.CheckoutResponse, error) {
    // Оставляем себе бюджет на сборку ответа: вниз отдаём не весь остаток.
    ctx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
    defer cancel()

    order, err := h.orders.GetOrder(ctx, &pb.GetOrderRequest{Id: req.OrderId})
    if err != nil {
        switch status.Code(err) {
        case codes.NotFound:                 // ожидаемый бизнес-исход, не инцидент
            return nil, status.Error(codes.NotFound, "заказ не найден")
        case codes.DeadlineExceeded:         // бюджет исчерпан — ретрай бессмысленен
            return nil, status.Error(codes.DeadlineExceeded, "не успели за бюджет")
        default:
            return nil, err
        }
    }
    return h.buildResponse(order), nil
}

Про ретраи: gRPC умеет их сам через service config — с экспоненциальной задержкой и, что важнее, с retry throttling, который глобально отключает повторы, когда доля ошибок превышает порог. Без такого предохранителя ретраи превращают деградацию в лавину; подробно это разбирается в статье про устойчивость.

Чем платят за gRPC

Честный список: браузер напрямую не умеет (нужен gRPC-Web и прокси); трафик не прочитать глазами в tcpdump и не потрогать curl без grpcurl; балансировка сложнее, потому что L4 балансировщик распределяет соединения, а не запросы, и долгоживущее HTTP/2-соединение прилипает к одному бэкенду (лечится клиентской балансировкой или service mesh); порог входа для внешних интеграторов заметно выше JSON. Вывод простой и почти универсальный: gRPC внутрь, REST наружу.

4. GraphQL: клиент владеет формой ответа

GraphQL (спецификация) решает конкретную боль: много разных клиентов с разными потребностями против одного набора эндпоинтов. Клиент присылает запрос, описывающий нужное дерево, сервер отдаёт ровно его.

type Query {
  order(id: ID!): Order
}

type Order {
  id: ID!
  totalAmount: Money!
  customer: Customer!
  items: [OrderItem!]!
}

Ценность реальна: исчезает и over-fetching, и водопад запросов, а фронтенд перестаёт ждать бэкенд ради нового поля. Но у модели есть три структурные проблемы, и все три надо решать осознанно.

Проблема 1: N+1 запросов. Резолвер items вызывается для каждого заказа отдельно. Запрос на 50 заказов даёт 51 обращение к базе. Лечение — DataLoader: батчинг обращений в пределах одного тика event loop плюс кэш на время запроса.

// DataLoader: собирает вызовы одного тика в один батч-запрос.
// Порядок результата ДОЛЖЕН соответствовать порядку ключей — иначе данные разъедутся.
import DataLoader from "dataloader";

const makeItemsLoader = (db: Db) =>
  new DataLoader<string, OrderItem[]>(async (orderIds) => {
    const rows = await db.query(
      `SELECT * FROM order_items WHERE order_id = ANY($1)`,
      [orderIds as string[]],
    );
    const byOrder = new Map<string, OrderItem[]>();
    for (const r of rows) {
      const list = byOrder.get(r.order_id) ?? [];
      list.push(r);
      byOrder.set(r.order_id, list);
    }
    return orderIds.map((id) => byOrder.get(id) ?? []);
  });

// Лоадер создаётся НА КАЖДЫЙ запрос, а не глобально:
// глобальный кэш переживёт мутацию и отдаст устаревшие данные другому пользователю.
export const buildContext = (db: Db, user: User) => ({
  user,
  loaders: { items: makeItemsLoader(db) },
});

Проблема 2: неограниченная стоимость запроса. Клиент может запросить дерево глубиной 15 с перекрёстными связями и положить базу одним HTTP-запросом. Рейт-лимит по числу запросов бесполезен — запросы неравноценны. Нужны: ограничение глубины, анализ стоимости (каждому полю назначается вес, бюджет списывается до выполнения) и в идеале persisted queries — режим, когда сервер принимает только заранее зарегистрированные запросы по хэшу. Последнее убирает почти весь класс проблем разом и дополнительно экономит трафик.

Проблема 3: HTTP-кэширование не работает. Всё идёт POST-ом на один URL, поэтому CDN и браузер отдыхают. Кэш переезжает внутрь: на уровень сущностей в клиенте (Apollo, Relay) и на уровень резолверов на сервере. Для read-heavy публичного API это серьёзный аргумент против: REST с ETag и CDN обслужит такой трафик радикально дешевле.

Отдельно про федерацию: Apollo Federation позволяет собрать один граф из подграфов разных команд. Это работающая технология и одновременно организационное обязательство — общий граф требует владельца, иначе он превращается в свалку несогласованных типов, где User означает три разные вещи.

5. Вебхуки: push наружу и его подводные камни

Вебхук — HTTP-callback: происходит событие, вы делаете POST на URL, который зарегистрировал клиент. Модель кажется тривиальной ровно до первого прода. Разница между наивной и рабочей реализацией — примерно вся эта глава.

Что здесь принципиально:

Транзакционный outbox. Событие пишется в БД в той же транзакции, что и изменение состояния, а отправкой занимается отдельный воркер. Иначе получаете dual-write: изменение зафиксировано, процесс упал до отправки, событие потеряно навсегда. Паттерн подробно разобран в статье про saga и outbox.

At-least-once — это данность, а не выбор. Клиент обязан быть готов к дублям: сеть теряет ответы, воркер падает после доставки, но до отметки. Поэтому в каждом вебхуке отправляйте стабильный event_id, а в документации прямым текстом требуйте дедупликации по нему.

Порядок не гарантирован. Ретраи и параллелизм переставляют события: order.updated может прийти раньше order.created. Два лечения: (а) включать в payload версию/timestamp, чтобы клиент отбросил устаревшее; (б) отправлять «тонкие» события — только идентификатор и тип, — а клиент сам запрашивает актуальное состояние через REST. Второй вариант заодно решает вопрос утечки данных в логи посредников.

Подпись обязательна. URL клиента публичен, значит на него может постучаться кто угодно.

# Проверка подписи вебхука на стороне получателя.
# Три обязательных элемента: HMAC от СЫРОГО тела, timestamp против replay,
# сравнение за постоянное время против timing-атак.
import hashlib, hmac, time

TOLERANCE_SECONDS = 300

def verify(raw_body: bytes, header: str, secrets: list[str]) -> bool:
    # Формат заголовка: "t=1771171200,v1=5257a8...,v1=9f2b..." — версий может быть
    # несколько во время ротации ключа: старый и новый действуют одновременно.
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    try:
        ts = int(parts["t"])
    except (KeyError, ValueError):
        return False

    if abs(time.time() - ts) > TOLERANCE_SECONDS:
        return False                       # старый перехваченный запрос не пройдёт

    signed_payload = f"{ts}.".encode() + raw_body
    provided = [v for k, v in
                (p.split("=", 1) for p in header.split(",")) if k == "v1"]

    for secret in secrets:                 # перебор нужен только на время ротации
        expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()
        if any(hmac.compare_digest(expected, p) for p in provided):
            return True
    return False

Тонкость, на которой ошибаются чаще всего: подписывать нужно сырое тело до парсинга JSON. Если фреймворк десериализовал и вы сериализуете обратно, порядок ключей и пробелы изменятся, и подпись развалится на ровном месте.

Дайте способ пережить простой. Клиент неизбежно полежит дольше вашего окна ретраев. Поэтому рядом с вебхуками нужен REST-эндпоинт вида GET /events?since=<cursor> — тогда после починки интеграция догоняет пропущенное сама, без вашего участия и без ручного replay из DLQ.

Чем ещё бывает push

Механизм Направление Держит соединение Когда уместно
Вебхуки сервер → чужой сервер нет интеграции с внешними системами
SSE сервер → браузер да, однонаправленно ленты, прогресс задач, уведомления
WebSocket двунаправленно да чат, совместное редактирование, игры
Long polling клиент инициирует почти фолбэк там, где остальное не проходит
Очередь (Kafka/SQS) сервер → сервер нет внутренний обмен, свои сервисы

Практическое правило: для браузера начинайте с SSE. Он работает поверх обычного HTTP, переживает прокси, умеет автопереподключение с Last-Event-ID из коробки. WebSocket берите, когда действительно нужен канал в обе стороны, — за него платят отдельным протоколом, собственным heartbeat и невозможностью использовать HTTP-кэш и стандартную авторизацию.

6. Сравнение по свойствам

Сводка по типовым решениям, которую можно использовать как чек-лист:

  • Публичный API для внешних разработчиков → REST + OpenAPI. Порог входа, документация, curl, кэшируемость, отсутствие обязательной кодогенерации.
  • Обмен между своими сервисами, чувствительный к латентности → gRPC. Дедлайны, стриминг, схема, дешёвая сериализация.
  • Много разнородных UI над общей моделью → GraphQL, если есть команда-владелец графа; иначе BFF.
  • Уведомления во внешние системы → вебхуки + outbox + подпись + догоняющий эндпоинт.
  • Внутренние асинхронные события → брокер, а не HTTP: см. событийную архитектуру.

И отдельно: эти стили сосуществуют. Нормальная зрелая система имеет gRPC внутри, REST на внешнем периметре, GraphQL или BFF для фронтенда и вебхуки для партнёров. Выбор делается на каждой границе отдельно, а не один раз на всю компанию.

7. Версионирование: центральная тема

Здесь ломается больше всего систем, поэтому начнём с определений, а не с приёмов.

Обратная совместимость (backward compatible) — новый сервер корректно обслуживает старого клиента. Прямая совместимость (forward compatible) — старый клиент не ломается о новый ответ. Прямая совместимость требует дисциплины от клиента: игнорировать неизвестные поля, не падать на новом значении enum, не валидировать ответ по closed-schema. Если ваши клиенты этого не делают, вы не сможете добавить ни одного поля — и это ограничение вашей системы, даже если формально виноват клиент.

Что не ломает совместимость: добавление опционального поля в ответ; добавление опционального параметра запроса со значением по умолчанию; добавление нового эндпоинта; добавление нового значения enum — но только если клиенты умеют его игнорировать (в protobuf для этого есть _UNSPECIFIED = 0).

Что ломает: удаление или переименование поля; смена типа ("42"42, число → строка для больших ID); ужесточение валидации; смена значения по умолчанию; смена кода ответа с 200 на 202; смена семантики при том же имени — самое коварное, потому что тесты зелёные, а клиенты считают деньги иначе.

Где размещать номер версии

  • В пути: /v1/orders. Очевидно, кэшируется, видно в логах, тривиально маршрутизируется. Формально «нересурсно» (один ресурс — два URL), практически — доминирующий вариант в индустрии.
  • В заголовке: Accept: application/vnd.example.v2+json. Чище по духу HTTP, но невидимо в логах и браузерной адресной строке, легко теряется в прокси и путает интеграторов.
  • В имени пакета (gRPC/protobuf): package orders.v2 — версия становится частью типа, две версии сосуществуют в одном бинаре без конфликтов.
  • По дате, закреплённой за клиентом: подход Stripe — клиент фиксируется на версии в момент первого вызова, а сервер держит цепочку трансформаций между версиями. Внутри всегда одна модель, наружу — много. Мощно и дорого: нужна инфраструктура и тесты на каждый переход, зато клиентов не приходится гнать на миграцию.

Главное правило: мажорную версию поднимайте как можно реже. Каждая новая версия — это удвоение эксплуатации, документации и тестов до тех пор, пока старая жива. А живёт она годами.

Expand / Migrate / Contract

Рабочий способ вносить ломающее изменение без ломающего релиза. Тот же приём, что и для миграций схемы БД: расширить, мигрировать, сузить.

Expand / Migrate / Contract на примере замены поля

  1. Expand. Добавляем новое поле рядом со старым. Сервер поддерживает оба и синхронизирует их в обе стороны: пришло старое — заполнить новое, пришло новое — заполнить старое.
  2. Migrate. Помечаем старое Deprecation (RFC 9745) и Sunset (RFC 8594), считаем использование по клиентам, а не суммарно, и адресно пишем тем, кто ещё не мигрировал.
  3. Contract. Удаляем, когда счётчик держит ноль. Не раньше и, что важнее, — не «когда вспомним».

Второй пункт — тот, который пропускают. Без per-client метрики использования поля вы не узнаете, кого сломаете, и поэтому не удалите старое поле никогда. Минимальная реализация:

# Счётчик использования устаревших полей: без него Contract не наступает никогда.
# Метку client_id берём из токена, а не из User-Agent — UA подделывается и врёт.
DEPRECATED_FIELDS = {"name": "2026-10-01", "shipping_address": "2026-12-15"}

def track_deprecated_usage(requested_fields: set[str], client_id: str, response) -> None:
    used = requested_fields & DEPRECATED_FIELDS.keys()
    if not used:
        return
    for field in used:
        metrics.counter("api.deprecated_field.used",
                        tags={"field": field, "client": client_id}).inc()
    # Клиент узнаёт о проблеме из ответа, а не из рассылки, которую не прочитал.
    earliest = min(DEPRECATED_FIELDS[f] for f in used)
    response.headers["Deprecation"] = "true"
    response.headers["Sunset"] = to_http_date(earliest)
    response.headers["Link"] = '</docs/migrations/v2>; rel="deprecation"'

Контрактные тесты вместо надежды

Совместимость надо проверять машиной, а не ревью. Три уровня, по возрастанию цены:

  • Линтер схемы в CI. Для protobuf — buf breaking, который сравнивает .proto с базовой веткой и валит сборку на несовместимом изменении. Для OpenAPI — oasdiff или аналог. Это самый дешёвый и самый эффективный контроль: пять минут настройки закрывают большую часть класса ошибок.
  • Consumer-driven contracts. Потребитель публикует свои ожидания (Pact, см. также описание паттерна у Фаулера), провайдер прогоняет их в своём CI. Ценность в том, что провайдер видит, какая часть контракта реально используется, — и может смело менять всё остальное.
  • Канареечный прогон реального трафика. Зеркалируем часть продовых запросов на новую версию и сравниваем ответы. Дорого, но ловит семантические расхождения, которые не видит ни один анализ схемы.

8. Типичные ошибки

  1. 200 OK с ошибкой в теле. Ломает ретраи, метрики, алерты и любой инструмент, который смотрит на статус. Код ответа — это часть контракта, а не украшение.
  2. Утечка внутренней модели наружу. Отдаёте ORM-сущность через сериализатор — и любое переименование колонки становится ломающим изменением API. DTO на границе не бюрократия, а изоляция; см. гексагональную архитектуру.
  3. Нет ограничения на размер выборки. Эндпоинт без обязательного limit рано или поздно получит запрос на весь датасет. Всегда ставьте максимум на стороне сервера.
  4. POST без идемпотентности в денежных операциях. Первый же сетевой таймаут даст двойное списание. Правило: любая операция с внешним эффектом принимает Idempotency-Key.
  5. Ретраи без бюджета и джиттера. Синхронизированные повторы после сбоя добивают систему при восстановлении. Нужны экспоненциальный backoff, джиттер и retry budget.
  6. Версия в URL, но код не разделён. /v1/ и /v2/ ведут в одну функцию с ветвлениями if version == 2. Через год это нечитаемо. Разделяйте на уровне слоя представления, держа одну доменную модель под ним.
  7. Enum без «неизвестного» значения. Клиент падает на значении, которого не знал. В protobuf _UNSPECIFIED = 0 обязателен; в JSON-API документируйте, что список открыт.
  8. Вебхуки без outbox. «Сохранили и отправили» двумя действиями — значит теряете события при падении между ними.
  9. Тайм-ауты по умолчанию. Клиент без явного таймаута ждёт вечно и держит поток/соединение. Таймаут должен быть у каждого исходящего вызова, и он должен быть меньше входящего.
  10. Пагинация через offset на больших таблицах. Работает на демо, деградирует линейно на проде, теряет и дублирует строки при конкурентных вставках.

9. Как это выглядит в проде

Дизайн-гайд, а не вкусовщина. Крупные компании публикуют внутренние стандарты API и проверяют их линтером в CI: Google API Improvement Proposals, Microsoft REST API Guidelines, Zalando RESTful API Guidelines. Ценность не в конкретных правилах, а в том, что они одинаковы — интегратор, освоивший один ваш эндпоинт, понимает остальные.

Schema-first и реестр схем. .proto и OpenAPI лежат в отдельном репозитории, из них генерируются клиенты и публикуются в реестр (Buf Schema Registry или внутренний артефакторий). Проверка совместимости — обязательный шаг пайплайна, а не ответственность ревьюера.

Шлюз на периметре. API gateway снимает с сервисов сквозные задачи: аутентификация, рейт-лимит, TLS, квоты, трансформация версий, логирование. Важно не перегрузить его бизнес-логикой, иначе он превращается в ESB — централизованный компонент, изменение которого требует координации всех команд, то есть ровно то, от чего уходили в микросервисах.

Наблюдаемость по клиентам, а не только по эндпоинтам. Метрики размечаются client_id и версией API. Это единственный способ ответить на вопросы «кого сломает удаление поля», «кто генерирует 80% нагрузки» и «кому звонить перед отключением v1».

Явные лимиты и их коммуникация. Рейт-лимит отдаётся заголовками (RFC 9331 / RateLimit-заголовки), превышение — 429 с Retry-After. Клиент, который видит остаток бюджета, ведёт себя кооперативно; клиент, который узнаёт о лимите только по отказу, ретраит и делает хуже.

Бюджет ошибок и SLA на депрекацию. Зрелые платформы публикуют политику: минимум 12 месяцев между Deprecation и Sunset для публичных API, минимум один квартал — для внутренних. Предсказуемость правил ценнее их мягкости.

10. Мини-итог

  • Стиль API — это выбор формы связанности, а не формата данных. Решайте по осям: кто инициирует, что является единицей контракта, кто владеет формой ответа, насколько строга схема.
  • REST полезен ограничениями, а не буквой: stateless даёт масштабирование, честная семантика методов даёт бесплатную инфраструктуру ретраев и кэша. Уровень 2 по Ричардсону — нормальная точка остановки.
  • gRPC — правильный выбор для внутреннего обмена: схема, кодогенерация, стриминг и, главное, распространяемые дедлайны. Наружу — почти всегда REST.
  • GraphQL решает проблему разнородных клиентов и создаёт три новые: N+1, неограниченную стоимость запроса и потерю HTTP-кэша. Все три решаемы, но требуют владельца графа.
  • Вебхуки без outbox, подписи, ретраев с backoff и догоняющего эндпоинта — не интеграция, а источник инцидентов.
  • Версионирование начинается с определений совместимости и заканчивается телеметрией по клиентам. Expand / Migrate / Contract плюс buf breaking в CI закрывают большую часть рисков.
  • Мажорных версий должно быть мало. Аддитивных изменений — сколько угодно.

Источники

Что дальше

Мы разобрали, как выглядит граница системы снаружи. Следующий вопрос — что происходит, когда через эту границу идёт на порядок больше трафика, чем рассчитывали: где ставить кэш, как его инвалидировать и когда пора резать данные по шардам.

Кэширование и масштабирование: уровни, инвалидация, шардирование

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов