API Design: проектирование границы между системами
Предпосылки: Паттерны надёжности (idempotency, retry, timeout), Message Queues (асинхронная коммуникация), Выбор хранилища (паттерн доступа как критерий), HTTP (методы, заголовки, статус-коды).
← Выбор хранилища под паттерн доступа | Микросервисы →
Предыдущие заметки определили, какие хранилища использовать и как обеспечить надёжность при сетевых вызовах. Но хранилище не обслуживает клиентов напрямую — запросы проходят через приложение. Пока всё приложение живёт в одном процессе, внутренние вызовы — это обычные вызовы методов: PaymentService.charge(order) принимает Ruby-объект и возвращает результат. Никакой сериализации, никакого контракта, никакой сети. Когда у приложения появляется внешняя граница — мобильный клиент, партнёрская интеграция, отдельный сервис в датацентре — вопрос смещается: не как хранить данные, а как предоставить к ним доступ через сеть.
Когда вызов метода становится вызовом через сеть
Интернет-магазин. Монолитное приложение. Контроллер оформления заказа вызывает внутренние сервисы — каждый из них обычный класс в том же процессе:
OrdersController#create
├── Order.create!(params)
├── PaymentService.charge(order)
├── InventoryService.reserve(items)
└── NotificationService.notify(user)
Появляется мобильное приложение для покупателей. Появляется партнёрский API для внешних магазинов. Со временем появляются отдельные сервисы внутри датацентра. В каждом из этих случаев вызов пересекает сетевую границу, и возникает набор вопросов, которых в монолите не существовало.
Формат. Ruby-объект нельзя отправить по сети. Нужна сериализация — JSON, бинарный формат, XML. Какие поля включать, как представить вложенные объекты, как кодировать даты и деньги.
Контракт. Как вызывающая сторона узнает, какие поля отправлять? Как вызываемая сторона узнает, что ей пришлют? Контракт должен быть зафиксирован, и обе стороны должны ему следовать.
Адресация. PaymentService.charge — это какой URL? Какой HTTP-метод? Или это вообще не HTTP?
Эволюция. Через полгода формат ответа меняется. Как не сломать клиентов, которые используют старый формат?
Ошибки. Вызов метода бросает исключение, вызов через сеть может зависнуть, вернуть невалидные данные, оборваться посередине. Как отличить «платёж отклонён банком» от «сеть между сервисами упала»?
Эти вопросы и есть API Design — проектирование границы так, чтобы системы по обе стороны могли развиваться независимо.
Требования к этой границе зависят от того, кто по другую сторону. У платёжного сервиса два типа клиентов: Orders-сервис, вызывающий его 5000 раз в секунду из того же датацентра, и мобильное приложение, показывающее историю платежей через интернет. Для мобилки критичны читаемость (разработчик отлаживает через curl), кэширование на CDN, экономия трафика. Для inter-service — throughput (тысячи вызовов в секунду), компактность сериализации, строгий контракт с типизацией. Эти два вектора — удобство и эффективность — и определяют, почему существует не один способ построить API, а три основных: REST, GraphQL, gRPC. Каждый оптимизирует своё.
REST: ресурсы и HTTP-семантика
REST (Representational State Transfer) — конвенция построения API вокруг ресурсов. Клиент никогда не видит ресурс напрямую — он получает его представление (representation). GET /orders/42 возвращает не объект Order, а JSON-документ, представляющий текущее состояние заказа. Клиент может изменить поля и отправить обратно через PUT — передав серверу новое состояние. Отсюда название: передача состояния через представления.
Рой Филдинг описал REST в диссертации 2000 года как набор архитектурных ограничений. На практике то, что называют «REST API», — это конвенция: ресурсы адресуются URL, операции выражаются HTTP-методами, данные передаются в JSON. Полную модель Филдинга (включая HATEOAS — гиперссылки в ответах для навигации между ресурсами) реализуют единицы.
Свойства HTTP-методов
От свойств методов зависят конкретные решения в инфраструктуре: что кэшировать, что повторять при ошибке, как настраивать load balancer.
Safe (безопасный) — метод не изменяет состояние на сервере. Это обещание: запрос только читает. Safe-методы: GET, HEAD, OPTIONS.
Idempotent (идемпотентный) — повторный вызов даёт тот же результат. Все safe-методы автоматически idempotent (чтение одних данных дважды — тот же результат). Плюс PUT и DELETE: удалить заказ дважды — заказ всё равно удалён.
Non-idempotent — POST и PATCH. POST создаёт новый ресурс при каждом вызове. PATCH — спецификация не гарантирует идемпотентность, потому что патч может содержать операцию вроде {"op": "increment", "path": "/counter", "value": 1}, меняющую состояние при каждом вызове.
Safe? Idempotent? Кэшируется? Auto-retry?
GET ✓ ✓ ✓ ✓
HEAD ✓ ✓ ✓ ✓
PUT ✗ ✓ ✗ ✓
DELETE ✗ ✓ ✗ ✓
POST ✗ ✗ ✗ ✗
PATCH ✗ ✗ ✗ ✗
Кэширование привязано к safe, не к idempotent. CDN, браузер, reverse proxy кэшируют только safe-методы. DELETE идемпотентен, но его никто не кэширует — он изменяет состояние. GET кэшируют именно потому, что он safe: повторный вызов не создаёт побочных эффектов, и можно вернуть сохранённый ответ.
Retry привязан к idempotent. Load balancer получил timeout от сервера на PUT /orders/42 — может безопасно повторить, потому что PUT идемпотентен. Timeout на POST /orders — повторить нельзя без риска создать дубликат. HAProxy, nginx, AWS ALB по умолчанию повторяют только idempotent-методы.
Из этого следует практическая проблема. Клиент делает POST /orders — создаёт заказ. Сеть обрывается после того, как сервер обработал запрос, но до того, как клиент получил ответ. Клиент не знает, создался заказ или нет, а POST non-idempotent — retry опасен. Решение — idempotency key: клиент генерирует уникальный ключ, отправляет в заголовке (Idempotency-Key: abc-123), сервер проверяет — если ключ уже видел, возвращает сохранённый ответ. Retry с ключом — exactly-once обработка для non-idempotent методов.
Проектирование ресурсов
Базовые ресурсы интернет-магазина очевидны: GET /products, GET /products/42, POST /products, PUT /products/42, DELETE /products/42. Это покрывает 80% случаев. Проблемы начинаются с оставшимися 20%.
Вложенные ресурсы. Товар имеет отзывы. GET /products/42/reviews — вложенный ресурс, URL читается как «отзывы товара 42». Альтернатива: GET /reviews?product_id=42 — плоский ресурс с фильтром. Оба работают, но если отзыв может принадлежать и товару, и магазину, вложенная структура создаёт два URL для одного ресурса: /products/42/reviews/7 и /shops/5/reviews/7 — два ключа в кэше, путаница с каноничным адресом. Правило: вложенность один уровень максимум, и только когда ресурс не существует без родителя. Позиции заказа (/orders/42/items) — да, item без заказа не существует. Отзывы — плоские с фильтром, потому что отзыв самостоятельная сущность.
Действия за пределами CRUD. Клиент отменяет заказ. Это не удаление — заказ остаётся со статусом «cancelled». Это не обновление всего ресурса. Это бизнес-действие. Подход «всё — ресурс» (POST /orders/42/cancellation — создание ресурса «отмена») формально RESTful, но натянут. Подход «действие в URL» (POST /orders/42/cancel) — не чистый REST, но читаем и однозначен. Stripe использует POST /charges/{id}/refund, GitHub — PUT /repos/{owner}/{repo}/subscription. CRUD покрывает операции с данными, для бизнес-действий (cancel, approve, refund, retry) — POST /resource/{id}/action.
Over-fetching: проблема избыточных данных
Мобильное приложение запрашивает GET /orders/42. Ответ включает данные заказа, товары, покупателя с адресом и лояльностью, доставку, платёж — десятки полей. На главном экране «Мои заказы» нужны только id, status, created_at, items_count. Всё остальное — лишнее. При 50 заказах на странице лишние данные — десятки килобайт впустую, что существенно для мобильного клиента на 4G.
BFF (Backend for Frontend) — отдельный «фасад» для каждого типа клиента. Один для мобилки (компактные ответы), один для веба (больше данных). Каждый BFF знает потребности своего клиента и формирует ответ точно под него. Trade-off: дублирование логики между BFF. При двух клиентах терпимо, при пяти — начинает болеть.
Sparse fieldsets (?fields[orders]=status,created_at&fields[customers]=name) — клиент указывает, какие поля нужны. Выглядит элегантно, но на практике: парсинг параметров, валидация допустимых полей, условная загрузка ассоциаций, тестирование комбинаций. По сути это мини-язык запросов внутри REST. Ещё одна проблема — взрыв комбинаций для кэша: каждый набор ?fields= — отдельный ключ. Решается нормализацией (сортировка параметров в каноничный вид) или кэшированием полного ответа с вырезанием нужных полей на уровне middleware.
JSON:API — стандарт, предписывающий строгий формат. Ответ разделён на три секции: data содержит основной ресурс с атрибутами и ссылками на связи, relationships — часть data с указателями (type + id) на связанные ресурсы, included — связанные ресурсы, подгруженные по запросу через ?include=customer,items. Клиент управляет, какие связи подгружать, и какие поля запрашивать. JSON:API даёт часть гибкости GraphQL (выбор полей, включение связей), оставаясь в мире REST (HTTP-методы, кэширование GET, статус-коды). Trade-off: verbose формат, обёртки вокруг каждого значения.
Пагинация
Каталог интернет-магазина: 500 000 товаров. GET /products без ограничений — невозможен. Нужна пагинация. Два принципиально разных подхода.
Offset-based
GET /products?page=2&per_page=20 → SELECT * FROM products ORDER BY id LIMIT 20 OFFSET 20.
Простой, привычный, поддерживает произвольный переход на страницу 7 или 42. Но OFFSET 100000 требует, чтобы база прошла по индексу, прочитала и отбросила 100 000 строк, прежде чем вернуть следующие 20. При наличии индекса по полю сортировки это не full table scan — проход по B-tree, но с проверкой видимости (heap access) для каждой строки. Работа линейна от величины offset: на странице 5000 — сотни миллисекунд, на странице 25 000 — секунды. Кроме того, при вставках между запросами элементы «сдвигаются»: один товар может появиться дважды или быть пропущен.
Cursor-based
GET /products?after=abc123&limit=20 → SELECT * FROM products WHERE id > 42 ORDER BY id LIMIT 20, где abc123 — закодированный курсор с позицией последнего элемента.
Планировщик находит позицию 42 в B-tree за O(log n) и читает ровно 20 следующих записей. Не важно, первая это «страница» или миллионная — стоимость одинакова. Результат стабилен при вставках: «всё после элемента X» не зависит от того, что добавилось перед X.
Ограничения: нельзя перейти на произвольную страницу, и сортировка должна быть по уникальному упорядоченному полю. WHERE id > 42 работает, потому что id уникален и монотонно растёт. Сортировка по price создаёт неоднозначность: два товара с ценой 1000 — курсор price > 1000 пропустит второй. Решение — составной курсор: WHERE (price, id) > (1000, 42), что усложняет и запрос, и кодирование.
Выбор
Offset — для интерфейсов, где данных мало (сотни-тысячи), пользователю важно перейти на конкретную страницу, глубокая пагинация маловероятна. Cursor — для API с бесконечным скроллом (каталог, лента заказов, уведомления), где данных много и глубокая пагинация реальна. Гибридный вариант — ограничить глубину offset: страницы выше 500 возвращают ошибку. Stripe, Twitter, Facebook используют cursor-based для публичных API.
Версионирование
Интернет-магазин. API используют три клиента: веб-приложение (деплоится мгновенно), iOS-приложение (обновление через App Store, 30% пользователей на старой версии месяцами), партнёрские интеграции (десятки магазинов написали код против API).
Поле price было целым числом в копейках, стало объектом {amount: 100, currency: "USD"}. Это breaking change — клиент, ожидающий число, получает объект и падает.
Стратегии
URL path: GET /v1/products/42 vs GET /v2/products/42. Самый распространённый подход: Stripe, GitHub, Google Maps. Явный, легко маршрутизировать на уровне nginx или load balancer. Header: GET /products/42 с Accept: application/vnd.myapi.v2+json. URL не меняется, что чище с точки зрения REST (URL идентифицирует ресурс, версия — формат представления), но неудобно для отладки в браузере и легко забыть заголовок. Query param: GET /products/42?version=2 — редко используется, потому что query params обычно опциональны, а версия — нет.
Бизнес-логика не версионируется
Настоящая боль — не маршрутизация, а поддержка старых версий. Модель Order одна, база одна, но v1 возвращает price: 8500, а v2 возвращает price: {amount: 8500, currency: "USD"}. Принцип: бизнес-логика не дублируется между версиями — версионируется только представление. Один OrderService, разные сериализаторы для v1 и v2. Это работает для изменений формата ответа, но усложняется, когда меняется входной формат или поведение: v1 принимает price: 8500 и предполагает одну валюту, v2 требует {amount: 8500, currency: "USD"} и поддерживает мультивалютность. Бизнес-логика начинает ветвиться — каждая живая версия добавляет условия.
Отсюда два правила. Первое — минимизировать количество живых версий: для большинства систем реалистично поддерживать 2, максимум 3 одновременно. Stripe держит десятки, но у них отдельная система трансформации запросов между версиями — это значительные инженерные ресурсы. Второе — не все изменения требуют новой версии. Добавление нового поля в ответ — не breaking change, если клиент игнорирует неизвестные поля (а он должен). Добавление опционального параметра — тоже. Breaking changes — удаление поля, изменение типа поля, изменение семантики.
GraphQL: клиент определяет форму ответа
Мобильное приложение интернет-магазина. Экран «Мои заказы» показывает id, статус, дату. Экран «Детали заказа» — полную информацию с товарами, адресом, курьером. Экран «Повторить заказ» — только товары и количество. При BFF-подходе: три экрана — три endpoint’а. Каждое изменение UI требует изменения на бэкенде. При маленькой команде и одном-двух клиентах это Slack-сообщение и 10 минут работы. Но при сотнях фронтенд-команд координация «попроси бэкенд добавить поле» становится bottleneck’ом процесса.
GraphQL решает эту проблему иначе. Бэкенд определяет схему — типы данных, поля каждого типа, связи между ними:
type Order {
id: ID!
status: String!
createdAt: DateTime!
customer: Customer!
items: [OrderItem!]!
courier: Courier
}
type Courier {
id: ID!
name: String!
phone: String
photo: String
}Клиент сам решает, что ему нужно:
# Экран «Мои заказы» — минимум данных
query {
myOrders { id, status, createdAt }
}
# Экран «Детали заказа» — с курьером
query {
order(id: 42) {
id, status
items { productName, quantity, price }
courier { name, phone, photo }
}
}Нужно имя курьера на экране заказов? Фронтенд-разработчик добавляет courier { name } в свой запрос — без деплоя бэкенда, если тип Courier с полем name уже в схеме.
N+1 на уровне API
Клиент запрашивает 20 заказов, для каждого — данные курьера. Наивная реализация: резолвер orders делает один запрос к базе, затем для каждого заказа резолвер courier делает отдельный запрос — 1 + 20 = 21 запрос. Резолверы изолированы: резолвер courier не знает, что рядом резолвятся ещё 19 таких же.
Решение — DataLoader (паттерн, созданный Facebook). Резолвер courier не делает запрос сразу, а кладёт courier_id в очередь. Когда все резолверы текущего уровня отработали, DataLoader собирает накопленные ID и делает один batch-запрос: SELECT * FROM couriers WHERE id IN (3, 7, 12, ...). По сути это отложенный includes, но на уровне API вместо ORM.
Даже с DataLoader клиент может построить запрос произвольной глубины: orders → items → product → category → parentCategory → ... — каждый уровень вложенности порождает новый batch. Поэтому production-серверы ограничивают глубину запроса (обычно 5–10 уровней) и complexity score (каждому полю назначается «вес», сумма ограничена).
Trade-offs
Все запросы GraphQL — POST (тело содержит запрос на языке GraphQL). POST не safe → CDN не кэширует. Для API с высоким трафиком чтения (каталог товаров, публичные данные) потеря CDN-кэширования — существенная цена.
Схему нужно проектировать осознанно: бездумное выставление всех полей всех моделей создаёт проблемы с безопасностью (утечка internal-полей), производительностью (тяжёлые связи без ограничений) и обратной совместимостью (удаление поля из схемы — breaking change).
GraphQL также удобен как инструмент быстрого прототипирования: клиентская библиотека (Apollo Client) читает схему через introspection, автогенерирует типы, даёт нормализованный кэш на фронте — снимая необходимость отдельно писать документацию, клиентский код и state management. При команде, владеющей GraphQL-стеком, суммарная стоимость (сервер + клиент + документация) может оказаться ниже, чем REST + OpenAPI + Redux.
Когда оправдан
GraphQL оправдан, когда много разных клиентов с разными потребностями в данных и координация через BFF дороже поддержки схемы. Или когда публичный API обслуживает неизвестных клиентов: GitHub перешёл на GraphQL, потому что REST API v3 разрастался бесконечными endpoint’ами под разные комбинации данных. При этом GitHub не даёт неограниченный доступ — complexity scoring, rate limiting по вычислительной стоимости, ограничение глубины. Это серьёзная инженерная работа, которая окупается при масштабе.
GraphQL не оправдан при одном-двух клиентах и маленькой команде, где BFF или «попроси бэкенд добавить поле» работает быстро. Сложность инфраструктуры (схема, резолверы, DataLoader, мониторинг per-field) в таком случае не окупается, если только команда не владеет GraphQL-стеком настолько, что разработка с ним быстрее, чем с REST.
gRPC: эффективность между сервисами
Вернёмся к двум клиентам платёжного сервиса. Для мобилки — REST или GraphQL. А между Orders и Payment внутри датацентра при 5000 RPS — каждый вызов через REST означает: сериализация в JSON (текстовый формат — ключи, кавычки, скобки), 200–500 байт HTTP/1.1 заголовков, на принимающей стороне парсинг JSON и ручная валидация полей. При 5000 RPS это складывается: JSON-представление запроса в 3–5 раз больше эквивалентного бинарного, парсинг текста требует лексического анализа. Работает, но неэффективно.
gRPC (Remote Procedure Call от Google) строится на двух технологиях: Protocol Buffers (Protobuf) для сериализации и HTTP/2 для транспорта.
Protobuf: контракт и сериализация
Интерфейс описывается в .proto-файле:
service PaymentService {
rpc Charge(ChargeRequest) returns (ChargeResponse);
rpc Refund(RefundRequest) returns (RefundResponse);
}
message ChargeRequest {
int64 order_id = 1;
int64 amount = 2;
string currency = 3;
}
message ChargeResponse {
string charge_id = 1;
string status = 2;
}Из .proto-файла генератор создаёт код на любом языке — Go, Python, Ruby, Rust. Клиентский вызов выглядит как обычный метод: stub.charge(ChargeRequest.new(order_id: 42, amount: 8500, currency: "USD")). Но сетевая граница не исчезает: вызов может бросить GRPC::Unavailable, GRPC::DeadlineExceeded, GRPC::Internal. Все паттерны надёжности — timeout, retry, circuit breaker — нужны точно так же, как при REST. RPC создаёт иллюзию локального вызова в синтаксисе, но не в поведении.
Компактность. В JSON ключи передаются как строки: "order_id", "amount", "currency" — в каждом сообщении. В Protobuf ключи заменены номерами полей (1, 2, 3), кодируемыми одним-двумя байтами. Числа кодируются в varint — 8500 занимает 2 байта вместо 4 символов. Тот же ChargeRequest в JSON — ~60 байт, в Protobuf — ~15 байт. Парсинг бинарного формата в 5–10 раз быстрее — нет лексического анализа, поля читаются по смещению.
Контракт как код. .proto-файл — не документация, которую можно проигнорировать. Из него генерируется код. Если сервер изменит формат — клиентский код не скомпилируется (в типизированных языках) или упадёт при десериализации. В REST с JSON контракт живёт в документации (OpenAPI/Swagger), и ничто не мешает серверу вернуть формат, отличный от документированного — клиент узнает об этом в runtime.
HTTP/2: мультиплексирование
REST обычно работает поверх HTTP/1.1: одно TCP-соединение — один запрос в каждый момент (head-of-line blocking). Для 10 параллельных запросов — 10 TCP-соединений. gRPC работает поверх HTTP/2: одно TCP-соединение, множество запросов одновременно через streams. При 5000 RPS между сервисами это существенно: вместо пула из сотен соединений — одно-два с мультиплексированием.
Обратная совместимость через номера полей
Protobuf обеспечивает обратную совместимость на уровне формата — элегантнее, чем URL-версионирование в REST.
Добавление поля безопасно в обе стороны. Новый сервер отправляет поле 4 (description) — старый клиент его молча игнорирует. Старый сервер не отправляет поле 4 — новый клиент получает значение по умолчанию (пустую строку для string, 0 для int64).
Удаление поля опаснее, чем кажется. Protobuf не упадёт и не выбросит ошибку. Старый клиент читает удалённое поле 3 из ответа нового сервера — получает значение по умолчанию: пустую строку "" для string. Не ошибку — тихо неправильные данные. Клиент обрабатывает заказ с currency = "" и не замечает проблемы. Это опаснее, чем crash: в JSON, если поле исчезло, парсер вернёт nil, и код упадёт явно.
Отсюда правило: никогда не удаляй поля и не переиспользуй их номера. Вместо удаления — reserved:
message ChargeRequest {
int64 order_id = 1;
int64 amount = 2;
reserved 3; // бывший currency, номер заблокирован
string currency_code = 4; // новое поле с новым номером
}reserved 3 гарантирует, что никто случайно не создаст поле с номером 3 — компилятор выдаст ошибку. Старые клиенты продолжают слать поле 3 — сервер его игнорирует. Новые клиенты используют поле 4. По сути, обратная совместимость встроена в формат: новая версия не требует /v2/, пока соблюдаются правила — добавлять безопасно, удалять через reserved, переименовывать бесплатно (имена не передаются по сети, только номера).
Streaming
HTTP/1.1 не поддерживает полноценный streaming. Существуют workarounds: Server-Sent Events (SSE) — сервер держит HTTP-соединение открытым и шлёт текстовые события, но только в одну сторону. WebSocket — отдельный протокол, начинающийся как HTTP-запрос (upgrade handshake) и переключающийся на полнодуплексное TCP-соединение.
gRPC использует HTTP/2 streams для реализации четырёх режимов взаимодействия:
service DeliveryService {
// Unary — как REST: один запрос, один ответ
rpc GetStatus(StatusRequest) returns (StatusResponse);
// Server streaming — один запрос, поток ответов
rpc WatchStatus(StatusRequest) returns (stream StatusUpdate);
// Client streaming — поток запросов, один ответ
rpc UploadLocations(stream Location) returns (Summary);
// Bidirectional — поток в обе стороны
rpc Chat(stream Message) returns (stream Message);
}Unary — ровно одно сообщение туда, ровно одно обратно. Соединение открылось, данные прошли, stream закрылся.
Server streaming — клиент отправляет один запрос, сервер отвечает последовательностью сообщений по мере их появления. Orders-сервис вызывает WatchStatus(order_id: 42) — сервер доставки отправляет обновления по мере изменений: picked_up, in_transit, delivered. Нет polling’а (при 5000 заказов в доставке polling — 1000 RPS, из которых 99% ответов «ничего не изменилось»), нет лишних запросов, одно соединение.
Client streaming — зеркально: клиент шлёт поток сообщений, сервер отвечает одним. Курьер отправляет GPS-координаты каждые 5 секунд, сервер возвращает сводку маршрута по завершении.
Bidirectional — обе стороны шлют потоки одновременно, не дожидаясь ответа.
Выбор API по типу клиента
REST GraphQL gRPC
────────────────────────────────────────────────────────────────
Формат данных JSON (текст) JSON (текст) Protobuf (бинарный)
Транспорт HTTP/1.1 HTTP/1.1 HTTP/2
Контракт OpenAPI (опц.) Schema (обяз.) .proto (обяз.)
Кэширование (CDN) GET кэшируется нет (всё POST) нет (бинарный, H2)
Гибкость запроса фиксированный клиент выбирает фиксированный
Типизация слабая строгая (схема) строгая (protobuf)
Читаемость curl, браузер GraphiQL нет (бинарный)
Версионирование URL/header эволюция схемы эволюция proto
Streaming SSE/WebSocket subscriptions встроенный (4 режима)
────────────────────────────────────────────────────────────────
Вернёмся к интернет-магазину. Четыре типа клиентов — четыре решения.
Мобильное приложение для покупателей — REST. Два вида клиентов (iOS/Android) — GraphQL не оправдан. CDN-кэширование GET-запросов важно для каталога товаров. BFF формирует ответы точно под экраны мобилки, экономя трафик на 4G. Cursor-based пагинация для бесконечного скролла каталога.
Веб-панель для продавцов — серверный рендеринг по умолчанию (быстрее в разработке для CRUD-админки). REST API, если фронтенд на отдельном фреймворке. GraphQL оправдан, если команда владеет стеком и хочет использовать Apollo для автогенерации типов и state management — суммарная стоимость (сервер + клиент + документация) может оказаться ниже.
Внутренние сервисы — REST, если нагрузка невысока (проще дебажить, curl для отладки, читаемые логи). gRPC, когда профилирование показывает, что сериализация или overhead HTTP/1.1 — реальная проблема, или когда нужен streaming (обновления статуса доставки). Для асинхронной коммуникации ни REST, ни gRPC — а message queue: Orders → Payment синхронно (нужен ответ сейчас), Orders → Notification асинхронно (через очередь).
Партнёрский API для внешних магазинов — REST. Партнёры написали код против API, обновляются нечасто — критичны стабильность и версионирование. URL path versioning (/v1/), строгие deprecation policies, длинные сроки поддержки старых версий. GraphQL для партнёров даёт им гибкость строить произвольные запросы, что создаёт непредсказуемую нагрузку — защита (complexity scoring, rate limiting по вычислительной стоимости) требует значительных инженерных ресурсов, оправданных при масштабе GitHub, но не для большинства систем.
Sources
- Fielding, 2000, Architectural Styles and the Design of Network-Based Software Architectures — оригинальная диссертация REST, архитектурные ограничения
- Kleppmann, 2017, Designing Data-Intensive Applications, Chapter 4 — форматы кодирования (JSON, Protobuf, Avro), эволюция схемы
- Google, 2015, gRPC Documentation — спецификация gRPC, Protobuf, streaming-режимы
- Facebook, 2015, GraphQL Specification — спецификация GraphQL, схема, резолверы