Как делать11 октября 2026 г.12 мин чтения
API и интеграцииШаг 1 из 10

REST или GraphQL

Коротко

REST отдаёт данные ресурсами с отдельными адресами, поэтому его ответы кэшируют браузер, CDN и прокси, а ошибки видны по HTTP-статусу. GraphQL принимает на один адрес запрос с перечнем нужных полей и собирает связанные данные за один круг по сети, но кэш переезжает на клиент, ошибки — в тело ответа, а сервер обязан ограничивать глубину и стоимость запросов. REST разумен для публичных API, интеграций и файлов, GraphQL — для нескольких клиентов с разными экранами над связанными данными.

СравнениеУровень: СреднийREST и GraphQL

После материала вы сможете

  • посчитать, сколько HTTP-запросов и кругов по сети нужно экрану в REST и в GraphQL, и объяснить, куда при этом уходит работа сервера
  • объяснить, почему ответ GET /services/s1 кэширует CDN, а POST /graphql — нет, и чем это заменяют в GraphQL
  • различить ошибку запроса и ошибку выполнения GraphQL и предсказать HTTP-статус ответа
  • выбрать стиль API (REST, GraphQL, gRPC или tRPC) по таблице «когда что»

Проверено на

Node.js 24 LTS (24.18)GraphQL Yoga 5.24.4graphql-js 16.14.2GraphQL spec (September 2025)

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Одна задача — два подхода
  2. 02Выборка данных: over- и under-fetching
  3. 03Кэширование: HTTP против клиентского кэша
  4. 04Ошибки и коды ответов
  5. 05Безопасность и стоимость запросов
  6. 06Версии, инструменты и альтернативы: gRPC и tRPC
  7. 07Таблица выбора

Одна задача — два подхода

Сравним стили на одном экране: карточка услуги в каталоге студии — название, категория и отзывы. Данные одни и те же, небольшая «база» в памяти, к ней два учебных сервера: REST на node:http и GraphQL на GraphQL Yoga из урока «GraphQL: схема, запросы и мутации». Все ответы ниже — реальный вывод. Соглашения REST — ресурсы, методы, коды, формат ошибок Problem Details, версии — разобраны в уроке «Проектирование API», здесь мы их не повторяем.

REST (Representational State Transfer) — стиль, в котором данные представлены ресурсами со своими адресами, а действие выражает HTTP-метод. GraphQL — язык запросов и среда выполнения: сервер публикует схему типов, а клиент присылает на один адрес, обычно /graphql, документ с перечнем нужных полей. Сервер возвращает ровно эти поля — в той же форме, что и запрос.

src/rest.js
// Учебная REST-заглушка над той же «БД», что и GraphQL-сервер: только GET,
// без авторизации. Каждый адрес — отдельный ресурс со своим ETag.
import { createServer } from "node:http";
import { createHash } from "node:crypto";
import { createDb } from "./db.js";

const db = createDb({ onQuery: (sql, params) => console.log("[db]", sql, JSON.stringify(params)) });

const routes = [
  [/^\/services\/(\w+)$/, ([id]) => db.findService(id)],
  [/^\/services\/(\w+)\/reviews$/, ([id]) => db.reviewsByServiceIds([id])],
  [/^\/categories\/(\w+)$/, async ([id]) => (await db.categoriesByIds([id]))[0] ?? null],
];

function notFound(res) {
  res.writeHead(404, { "content-type": "application/problem+json" });
  res.end(JSON.stringify({ type: "about:blank", title: "Not Found", status: 404 }));
}

createServer(async (req, res) => {
  const { pathname } = new URL(req.url, "http://localhost");
  const route = req.method === "GET" && routes.find(([pattern]) => pattern.test(pathname));
  if (!route) return notFound(res);

  const data = await route[1](pathname.match(route[0]).slice(1));
  if (data === null) return notFound(res);

  const body = JSON.stringify(data);
  const etag = `"${createHash("sha256").update(body).digest("base64url").slice(0, 16)}"`;
  const headers = { "cache-control": "public, max-age=60", etag };
  if (req.headers["if-none-match"] === etag) {
    res.writeHead(304, headers); // тело не передаётся: у клиента актуальная копия
    return res.end();
  }
  res.writeHead(200, { ...headers, "content-type": "application/json" });
  res.end(body);
}).listen(4401, () => console.log("REST: http://localhost:4401"));
Учебная REST-заглушка: три ресурса, ETag и Cache-Control на каждом ответе. Не production-код: нет авторизации, ограничения частоты и обработки ошибок базы

REST-клиент собирает карточку из трёх ресурсов. Адрес категории он узнаёт только из первого ответа (поле categoryId), поэтому запросы идут минимум в два последовательных круга: сначала услуга, затем параллельно категория и отзывы.

terminal
$ curl -s http://localhost:4401/services/s1
{"id":"s1","title":"Корпоративный сайт","categoryId":"c1"}
$ curl -s http://localhost:4401/services/s1/reviews
[{"id":"r1","serviceId":"s1","rating":5,"text":"Сайт сдали в срок"}]
$ curl -s http://localhost:4401/categories/c1
{"id":"c1","name":"Веб-разработка"}
Реальный вывод: три запроса к REST-заглушке

GraphQL-клиент описывает весь экран одним документом. Значение id передаётся отдельной переменной, а не вклеивается в текст запроса:

requests/card.json
{
  "query": "query ServiceCard($id: ID!) { service(id: $id) { title category { name } reviews { rating text } } }",
  "variables": { "id": "s1" }
}
Запрос карточки: операция ServiceCard с переменной $id
terminal
$ curl -s http://localhost:4400/graphql -H "content-type: application/json" -d @requests/card.json
{"data":{"service":{"title":"Корпоративный сайт","category":{"name":"Веб-разработка"},"reviews":[{"rating":5,"text":"Сайт сдали в срок"}]}}}
Реальный вывод: один запрос — ответ повторяет форму документа

REST

  • 3 HTTP-запроса, 2 последовательных круга по сети
  • Лишние для экрана поля: categoryId, serviceId, id
  • Каждый ответ кэшируется по своему адресу

GraphQL

  • 1 HTTP-запрос
  • Только запрошенные поля
  • Один адрес и метод POST: HTTP-кэш не работает без дополнительных мер

Выборка данных: over- и under-fetching

Overfetching — ответ содержит больше, чем нужно экрану. Ресурс услуги в REST отдаёт categoryId, а отзывы — id и serviceId: сервер не знает, какой клиент спрашивает, и возвращает представление целиком. Underfetching — обратная проблема: одного ресурса не хватает, и клиент делает дополнительные запросы. На мобильной сети каждый последовательный круг заметно задерживает экран.

В GraphQL клиент выбирает поля сам и проходит по связям в одном запросе. Документация graphql.org формулирует это прямо: запрос обходит связанные объекты и получает их за один раз вместо нескольких кругов, типичных для REST. Аргументы можно передать любому полю, а не только адресу целиком.

В REST те же проблемы решают проектированием. Рабочие варианты:

  • Встраивание связей параметром: GET /services/s1?include=category,reviews — его нужно спроектировать, задокументировать и защитить от слишком больших выборок.
  • Выбор полей: ?fields=title,category — то же самое, но для полей.
  • Отдельный endpoint под экран или слой BFF (backend for frontend — серверная прослойка, которая собирает данные под конкретный клиент). Просто и быстро, но каждый новый экран — новая работа на сервере.
  • Принять лишние поля: при небольших ответах и сжатии это часто дешевле любой из схем выше.

У гибкости GraphQL есть цена на сервере. Каждое поле вычисляет свой резолвер — функция, которая возвращает значение поля, — и наивная реализация списка порождает проблему N+1: один запрос за списком и ещё по запросу на каждый элемент. В уроке по GraphQL список из пяти услуг с категориями и отзывами без пакетной загрузки делает 11 запросов к базе, а с DataLoader — 3; оба числа проверены тестами.

Кэширование: HTTP против клиентского кэша

HTTP-кэш выбирает сохранённый ответ по ключу. RFC 9111 определяет ключ как минимум из метода и адреса запроса и уточняет, что многие кэши на практике сохраняют только ответы на GET и используют как ключ один адрес. У каждого ресурса REST свой адрес, поэтому браузер, CDN и обратный прокси кэшируют его, ничего не зная о вашем API. Заголовок ETag — версия ответа: клиент присылает её в If-None-Match, и если данные не изменились, сервер отвечает 304 Not Modified без тела.

terminal
$ curl -si http://localhost:4401/services/s1
HTTP/1.1 200 OK
cache-control: public, max-age=60
etag: "2-1XKP8a0VuDcTfE"
content-type: application/json

{"id":"s1","title":"Корпоративный сайт","categoryId":"c1"}
$ curl -si http://localhost:4401/services/s1 -H 'if-none-match: "2-1XKP8a0VuDcTfE"'
HTTP/1.1 304 Not Modified
cache-control: public, max-age=60
etag: "2-1XKP8a0VuDcTfE"

$ curl -si http://localhost:4400/graphql -H "content-type: application/json" -d @requests/card.json
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{"data":{"service":{"title":"Корпоративный сайт","category":{"name":"Веб-разработка"},"reviews":[{"rating":5,"text":"Сайт сдали в срок"}]}}}
Реальный вывод, служебные заголовки Date, Connection, Keep-Alive, Transfer-Encoding, Content-Length убраны. У ответа GraphQL по умолчанию нет ни ETag, ни Cache-Control

GraphQL обычно ходит одним методом POST на один адрес. RFC 9110 разрешает кэшировать ответ на POST только при явной свежести и заголовке Content-Location, совпадающем с адресом запроса, и запрещает отвечать сохранённым ответом на следующий POST. Для типичного GraphQL-сервера это значит: CDN и браузер его ответы не кэшируют.

Обходные пути описаны на graphql.org. Запросы на чтение (query) можно отправлять методом GET — тогда работает HTTP-кэш, — но длинный документ упирается в ограничения длины URL у браузеров и CDN. Это решают persisted queries: клиент шлёт хэш заранее сохранённого документа вместо его текста. На сервере кэшируют результаты: у Yoga есть плагин @graphql-yoga/plugin-response-cache, который хранит ответы, сбрасывает их после мутаций, отдаёт ETag и отвечает 304.

Главный кэш GraphQL — на клиенте. Клиентская библиотека раскладывает ответы на объекты и хранит каждый по ключу «тип + идентификатор», поэтому услуга, полученная в списке, сразу видна в карточке. graphql.org советует давать объектам глобально уникальный id или строить ключ из __typename и идентификатора.

Браузер, CDN, прокси

REST
Работают по адресу ресурса и Cache-Control
GraphQL
Не работают для POST; для GET — при коротких или сохранённых документах

Условные запросы (ETag, 304)

REST
Встроены в HTTP, реализуются на каждом ресурсе
GraphQL
Через серверный плагин кэша ответов

Клиентский кэш

REST
Обычно по адресу запроса
GraphQL
Нормализованный: объект по типу и id

Сброс после изменения

REST
По адресам изменённых ресурсов
GraphQL
По типам и id из ответа мутации

Как выбирать время жизни и сбрасывать кэш, когда данные изменились, — в разборе «Кэширование и инвалидация».

Ошибки и коды ответов

В REST результат сообщает статус: 404 — нет ресурса, 400 — неверный запрос; тело ошибки — в формате Problem Details. В GraphQL у ответа три ключа верхнего уровня — data, errors и extensions, — а смысл передаёт тело. Спецификация различает два вида ошибок.

  1. Ошибка запроса

    Синтаксис, неизвестное поле, переменная не того типа. Возникает до выполнения, поэтому ключа data в ответе нет.

  2. Ошибка выполнения

    Её выбросил резолвер. Остальные поля выполняются, ответ частичный: в data то, что удалось, в errors — сообщение и path до поля. Если упавшее поле объявлено non-null (!), null поднимается к ближайшему родителю, который может быть null.

terminal
$ curl -si http://localhost:4401/services/s404
HTTP/1.1 404 Not Found
content-type: application/problem+json

{"type":"about:blank","title":"Not Found","status":404}

$ curl -si http://localhost:4400/graphql -H "content-type: application/json" -d '{"query":"{ service(id: \"s404\") { title } }"}'
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{"data":{"service":null}}

$ curl -si http://localhost:4400/graphql -H "content-type: application/json" -d '{"query":"{ services(first: 100) { title } }"}'
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{"errors":[{"message":"first должен быть от 1 до 50","locations":[{"line":1,"column":3}],"path":["services"],"extensions":{"code":"BAD_USER_INPUT"}}],"data":null}
Реальный вывод: REST отвечает 404, GraphQL — 200 и null; ошибка выполнения в поле services: [Service!]! обнулила весь data

Статус ответа на ошибку запроса зависит от формата. Руководство graphql.org описывает это по спецификации GraphQL over HTTP: если клиент принимает тип application/graphql-response+json, сервер отвечает на неудачный запрос кодом 4xx, а со старым application/json коды 4xx и 5xx для таких ошибок не рекомендуются — отвечают 2xx. Ответ с непустым data — 2xx даже при ошибках: в HTTP нет кода «частичный успех».

terminal
$ curl -s -w '\nHTTP %{http_code}\n' http://localhost:4400/graphql -H 'content-type: application/json' -H 'accept: application/json' -d '{"query":"{ servise(id: \"s1\") { title } }"}'
{"errors":[{"message":"Cannot query field \"servise\" on type \"Query\". Did you mean \"service\" or \"services\"?","locations":[{"line":1,"column":3}],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]}
HTTP 200
$ curl -s -w '\nHTTP %{http_code}\n' http://localhost:4400/graphql -H 'content-type: application/json' -H 'accept: application/graphql-response+json' -d '{"query":"{ servise(id: \"s1\") { title } }"}'
{"errors":[{"message":"Cannot query field \"servise\" on type \"Query\". Did you mean \"service\" or \"services\"?","locations":[{"line":1,"column":3}],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]}
HTTP 400
Реальный вывод Yoga 5.24: одна и та же опечатка в поле — 200 или 400 в зависимости от Accept
  • Мониторинг по HTTP-статусам не видит большую часть ошибок GraphQL — считайте ответы с непустым errors и группируйте их по имени операции.
  • null в ответе означает либо «нет данных», либо «ошибка в поле». Отличить помогает path в errors.
  • Ожидаемые ошибки (неверный ввод, нет доступа) удобно отдавать со своим кодом в extensions.code, как BAD_USER_INPUT выше; неожиданные Yoga маскирует фразой Unexpected error..

Безопасность и стоимость запросов

У REST-маршрута стоимость известна при проектировании: GET /services/s1 — один-два запроса к базе. Лимиты частоты и права ставят на маршруты. В GraphQL запрос составляет клиент. Если в схеме есть цикл — у категории есть услуги, у услуги категория, — запрос categories { services { category { services … } } } растёт без предела, а сто алиасов одного поля умножают работу в сто раз. Поэтому руководство graphql.org по безопасности перечисляет меры, которых в REST-API обычно нет.

Доверенные документы (trusted documents)

Что ограничивает
Разрешены только заранее известные операции
Комментарий
Самая сильная мера, но только для своих клиентов: операции стороннего клиента заранее неизвестны

Пагинация

Что ограничивает
Размер списков
Комментарий
Любое поле-список, которое может вырасти, — с ограничением

Лимит глубины

Что ограничивает
Вложенность полей
Комментарий
Проверяется до выполнения; для вложенных списков — отдельный, меньший лимит

Лимит ширины и алиасов

Что ограничивает
Число полей верхнего уровня и алиасов
Комментарий
Защита от friends1 … friends100

Лимит пакета

Что ограничивает
Число операций в одном HTTP-запросе
Комментарий
Если сервер принимает пакеты запросов

Анализ стоимости

Что ограничивает
Суммарный «вес» полей
Комментарий
Запрос дороже порога отклоняется или списывается из бюджета клиента

Лимиты частоты в бизнес-логике

Что ограничивает
Дорогие операции
Комментарий
Подсчёт HTTP-запросов не отражает их стоимость

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

Авторизация в GraphQL — на уровне полей. Аутентификацию выполняет middleware до GraphQL, а решения о правах принимаются во время выполнения, в резолверах или слое бизнес-логики. В REST права обычно проверяют на маршруте и объекте; подход подробно разобран в уроке «Авторизация: роли и права».

Версии, инструменты и альтернативы: gRPC и tRPC

Версионирование. REST-API выпускают версиями — в пути, параметре или заголовке, — и старую версию выводят из эксплуатации по плану. GraphQL, по формулировке graphql.org, «занимает твёрдую позицию против версий»: клиент получает только запрошенные поля, поэтому новые типы и поля ничего не ломают. Устаревшее поле помечают встроенной директивой @deprecated(reason: …), следят, какие операции его ещё запрашивают, и удаляют, когда запросов не осталось.

Контракт

REST
OpenAPI — пишется отдельно или генерируется из кода
GraphQL
Схема SDL — часть сервера, доступна через интроспекцию

Проверка запроса

REST
Схемы JSON на сервере, если их подключили
GraphQL
Валидация по схеме до выполнения — всегда

Типы для клиента

REST
Генерация из OpenAPI
GraphQL
Генерация из схемы и конкретных операций

Отладка

REST
curl, DevTools, логи прокси — адрес говорит сам за себя
GraphQL
Песочница GraphiQL; в DevTools все запросы — POST /graphql, различать по operationName

Файлы и потоки

REST
Обычный multipart, Range, редиректы
GraphQL
Не для этого: подписанные ссылки или REST рядом

gRPC — фреймворк удалённого вызова процедур. Сервис и сообщения описывают в .proto-файле: Protocol Buffers служат и языком описания, и двоичным форматом сообщений, а компилятор protoc генерирует клиента и сервер на нужных языках. Кроме вызова «запрос — ответ» есть потоки от сервера, от клиента и двунаправленные, а клиент задаёт крайний срок (deadline) вызова. Транспорт — HTTP/2. Для браузера есть отдельный вариант gRPC-Web; в примере документации запросы браузера проходят через прокси Envoy. Типичное место gRPC — связь между сервисами внутри системы.

tRPC — реализация RPC, по словам документации, «для TypeScript-монорепозиториев». Схемы и генерации кода нет: клиент получает типы входа, выхода и ошибок процедур выводом TypeScript прямо из кода сервера. Отсюда наш вывод: tRPC удобен, когда фронтенд и бэкенд пишет одна команда на TypeScript в одном репозитории, и не подходит, если API нужен клиентам на других языках или внешним партнёрам.

Таблица выбора

Публичный API для партнёров, вебхуки, обмен с 1С и CRM

Что выбрать
REST
Почему
Понятен любому HTTP-клиенту, кэшируется по адресам, ошибки видны по статусам

Загрузка и раздача файлов, большие выгрузки

Что выбрать
REST (или подписанные ссылки хранилища)
Почему
GraphQL не рассчитан на файлы

Веб и мобильное приложение с разными экранами над связанными данными

Что выбрать
GraphQL
Почему
Каждый клиент берёт свои поля за один круг; схема — общий контракт

Много команд и сервисов за одним API

Что выбрать
GraphQL как общий слой
Почему
Единая схема вместо набора BFF под каждый клиент

Связь между внутренними сервисами, потоки, низкие задержки

Что выбрать
gRPC
Почему
Двоичный формат, генерация кода, потоковые вызовы, крайние сроки

Full-stack TypeScript, одна команда, один репозиторий

Что выбрать
tRPC
Почему
Типы клиента выводятся из сервера без схемы и генерации

Простой CRUD-сервис, мало клиентов, важен CDN

Что выбрать
REST
Почему
Меньше движущихся частей, кэш из коробки
  • Кто клиенты API: свои приложения, партнёры, другие сервисы?
  • Сколько разных экранов читают одни и те же данные и как часто меняются их требования?
  • Нужен ли кэш CDN для публичных данных?
  • Есть ли файлы, потоки, длинные выгрузки?
  • Кто будет ограничивать глубину и стоимость запросов и следить за errors в мониторинге?
  • На каких языках пишут клиенты — всё ли на TypeScript?

Дальше — практика: в уроке «GraphQL: схема, запросы и мутации» поднимаем сервер из этой статьи, пишем резолверы, решаем N+1 через DataLoader и ограничиваем глубину запросов.

Как проверено3 · пройденосверено 11 октября 2026 г.
  • запущено, работает

    src/rest.js (REST-заглушка на node:http) + вывод curl: три ресурса карточки, ETag/304, 404 Problem Details

    запущена на Node.js 24.18 во временном проекте, запросы curl к localhost:4401, вывод скопирован (служебные заголовки убраны)

  • запущено, работает

    requests/card.json + вывод GraphQL (сервер из урока graphql-osnovy: graphql-yoga 5.24.4, graphql 16.14.2, dataloader 2.2.3): карточка, s404 → null, BAD_USER_INPUT с data:null, валидация 200/400 по Accept, отсутствие Cache-Control/ETag

    сервер запущен на localhost:4400, запросы curl, вывод скопирован; журнал [db] обоих серверов — по 3 запроса на карточку

  • запущено, работает

    цифры 11 и 3 запросов к БД (N+1 без/с DataLoader)

    тесты node:test в проекте graphql-osnovy (11/11 pass)

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.