GraphQL
Схемы, резолверы, проблема N+1, кэширование и безопасность — и когда вместо него выигрывают REST или tRPC.
За 30 секунд
GraphQL — язык запросов для API: сервер публикует типизированную схему, и каждый клиент в одном запросе просит ровно те поля, которые ему нужны. Он отлично подходит, когда множеству разных клиентов нужны разные срезы одних и тех же данных. Но он же переносит сложность на сервер: батчинг, чтобы избежать N+1-запросов, более сложное HTTP-кэширование и защита от дорогих запросов.
Не в моём ежедневном стеке — я понимаю принципы и изучу детали, когда это понадобится проекту.
Почему это важно
- GraphQL важен, когда множеству клиентов (веб, мобильные, партнёры) нужны разные срезы богатой связанной модели данных.
- Строго типизированная схема становится контрактом и источником для сгенерированных клиентских типов и документации.
- Он уменьшает over-fetching и under-fetching, что помогает мобильным клиентам в медленных сетях.
- Понимание его цены объясняет, почему я обычно строю REST API с общими типизированными клиентами и когда бы я перешёл на GraphQL.
Как это работает
01Схемы, типы и резолверы
Представьте REST API как комплексный обед: каждый эндпоинт возвращает фиксированную тарелку. GraphQL — шведский стол с точным бланком заказа: клиент перечисляет блюда и порции, которые хочет. Сервер описывает всё доступное в схеме, написанной на Schema Definition Language (SDL). У объектных типов (Product, Order) есть поля с типами; скаляры — базовые типы (String, Int, Boolean, ID); восклицательный знак означает non-null; списки обозначаются квадратными скобками. Специальные корневые типы задают точки входа: Query — для чтения, Mutation — для записи, Subscription — для обновлений в реальном времени. За каждым полем стоит резолвер — функция, возвращающая его значение. Сервер разбирает запрос, проверяет его по схеме, затем вызывает резолверы поле за полем, собирая ответ, который повторяет форму запроса. Неизвестные поля отклоняются ещё до выполнения какого-либо кода.
type Product {
id: ID!
name: String!
price: Int!
category: Category!
}
type Category {
id: ID!
name: String!
}
type Query {
products(first: Int = 20, after: String): [Product!]!
product(id: ID!): Product
}
type Mutation {
renameProduct(id: ID!, name: String!): Product!
}
02Query, mutation и subscription
Query запрашивает данные; клиент выбирает поля и может в одном запросе пройти по связям, например получить товары с названиями их категорий. Переменные делают запросы статичными и переиспользуемыми, а заодно позволяют использовать persisted queries. Фрагменты переиспользуют наборы полей между компонентами, и многие клиентские библиотеки размещают их рядом с UI-компонентами. Mutation изменяет данные и возвращает обновлённый объект, так что клиентский кэш может обновиться без дополнительного запроса; мутации в одном запросе выполняются последовательно. Проектируйте мутации вокруг бизнес-действий (placeOrder, cancelSubscription), а не общих обновлений таблиц, и возвращайте структурированные ошибки для ожидаемых сбоев, например валидации, внутри payload'а. Subscription'ы пушат обновления клиенту, обычно через WebSocket или server-sent events, — для вещей вроде статуса заказа или чата. Они добавляют stateful-соединения, которые нужно масштабировать и защищать, поэтому используйте их там, где живые обновления действительно важны.
03Проблема N+1 и DataLoader
Поскольку у каждого поля свой резолвер, наивный сервер выполняет один запрос за списком из 50 товаров, а затем по запросу на каждый товар за его категорией: 51 запрос на один запрос клиента. Это и есть проблема N+1, а вложенные запросы делают её хуже. Стандартное решение — батчинг с помощью DataLoader. Вместо немедленной выборки каждый резолвер запрашивает ключ у loader'а; loader собирает все ключи, запрошенные в одном такте event loop, достаёт их одним запросом (WHERE id IN ...) и возвращает результаты в правильном порядке. Он также кэширует в пределах запроса, так что одна и та же категория загружается один раз. Создавайте loader'ы на каждый запрос, никогда — глобально, чтобы кэшированные данные не могли утечь между пользователями. Мониторьте время резолверов и число запросов к базе на операцию, чтобы ловить регрессии.
import DataLoader from "dataloader";
// One set of loaders per request, so caching never leaks between users
export function createLoaders(db: Db) {
return {
categoryById: new DataLoader<string, Category | null>(async (ids) => {
const rows = await db.category.findMany({ where: { id: { in: [...ids] } } });
const byId = new Map(rows.map((c) => [c.id, c]));
return ids.map((id) => byId.get(id) ?? null);
}),
};
}
export const resolvers = {
Product: {
category: (p: Product, _args: unknown, ctx: Ctx) =>
ctx.loaders.categoryById.load(p.categoryId),
},
};
04Кэширование и безопасность
REST выигрывает от HTTP-кэширования: каждый URL — ресурс, который могут кэшировать браузеры, CDN и прокси. GraphQL обычно отправляет POST-запросы на один эндпоинт, так что этот слой исчезает. Ответы на это: нормализованные клиентские кэши (Apollo Client, urql, Relay), которые хранят объекты по типу и ID; persisted queries, отправляемые как GET с хэшем, что снова делает возможным кэширование в CDN; и серверное кэширование результатов резолверов с аккуратным разграничением по пользователям. Безопасность требует не меньшего внимания. Клиент может написать глубоко вложенный или огромный запрос, который обойдётся серверу в целое состояние, поэтому ограничивайте максимальную глубину, сложность или стоимость запроса, размер страниц и время выполнения. Ограничивайте частоту по стоимости, а не только по числу запросов. В продакшене подумайте об отключении introspection для приватных API или, что лучше, разрешите только persisted queries от ваших собственных клиентов. Авторизуйте в резолверах или бизнес-слое, при необходимости на уровне полей, — и никогда только на эндпоинте.
- Лимиты глубины и сложности плюс обязательная пагинация списков.
- Persisted queries: меньше запросы, кэширование в CDN, безопасный allow-list.
- Авторизация на уровне полей и DataLoader'ы на каждый запрос.
05GraphQL, REST или tRPC
Выбирайте по набору клиентов и устройству команды. GraphQL выигрывает, когда множество клиентов с разными потребностями работают с богатой связанной предметной областью, когда платформенная команда обслуживает несколько продуктовых или когда вы объединяете несколько бэкендов в один граф (federation). REST выигрывает для публичных API, простых моделей ресурсов, загрузки и скачивания файлов, вебхуков и всего, что выигрывает от HTTP-кэширования и привычных инструментов; с OpenAPI вы всё равно получаете типизированные клиенты. tRPC выигрывает в TypeScript-монорепозиториях, где одна команда владеет и сервером, и клиентом: процедуры вызываются как функции со сквозными типами и почти без накладных расходов на схему, но это только TypeScript, и он не задуман как публичный, независимый от языка API. Многие системы их смешивают: REST или tRPC для внутренних приложений, публичный REST API для партнёров и GraphQL только там, где его гибкость окупает его сложность.
Когда применять
- Нескольким клиентам (веб, iOS, Android, партнёры) нужны разные срезы одних и тех же связанных данных.
- Мобильные приложения, где важно сократить число round-trip'ов и размер ответов.
- Платформенная команда, предоставляющая единый граф поверх нескольких бэкенд-сервисов.
- Продуктовые команды, которые быстро итерируют UI и не хотят новый эндпоинт под каждый экран.
Когда не стоит
- Простые CRUD API или публичные API, где потребителям проще с REST и HTTP-кэшированием.
- TypeScript-монорепозиторий, где одна команда владеет обеими сторонами: tRPC или типизированный REST проще.
- Команды, у которых нет ресурсов на лимиты стоимости запросов, батчинг и мониторинг.
Альтернативы
- REST + OpenAPI
Ориентирован на ресурсы, кэшируется по HTTP, универсальные инструменты; типизированные клиенты генерируются из спецификации.
- tRPC
Сквозные типы TypeScript без слоя схемы; лучше всего внутри одной TypeScript-кодовой базы.
- gRPC
Быстрые бинарные контракты для вызовов между сервисами; неудобен напрямую из браузеров.
- Backend-for-frontend
Небольшой REST-слой на каждый клиент, формирующий данные точно под это приложение.
Лучшие практики
- Проектируйте схему вокруг предметной области и потребностей клиентов, а не вокруг таблиц базы.
- Используйте DataLoader на каждый запрос для каждого резолвера связи.
- Ограничивайте глубину, сложность и пагинацию и задавайте таймауты.
- Для собственных клиентов предпочитайте persisted queries.
- Авторизуйте в бизнес-слое и тестируйте права на уровне полей.
- Развивайте схему добавлением и помечайте поля @deprecated, прежде чем удалять их.
Частые ошибки
- Выпустить без батчинга и обнаружить в продакшене сотни запросов к базе на один запрос.
- Открыть неограниченные списки и глубокую вложенность, приглашая запросы, вызывающие отказ в обслуживании.
- Проверять права только на эндпоинте, так что вложенные поля сливают данные.
- Создать глобальный DataLoader, который кэширует данные одного пользователя для другого.
- Отражать таблицы базы один к одному, вытаскивая внутренности в публичный контракт.
Глубже
06Внутренности выполнения: nullability и частичные результаты
Ответ GraphQL может содержать одновременно data и errors. Когда резолвер выбрасывает ошибку, она добавляется в массив errors с path до упавшего поля, а само поле становится null. Если поле объявлено non-null, null недопустим, поэтому null «всплывает» до ближайшего nullable-родителя и может стереть целый список или весь объект data. Поэтому nullability — это решение дизайна, а не украшение. Помечайте поля non-null, когда они действительно всегда есть и дёшевы (id, name), и оставляйте nullable поля, зависящие от другого сервиса или проверки прав, чтобы один сбой портил одно поле, а не всю страницу. Клиенты должны уметь работать с частичными данными. Порядок выполнения резолверов тоже важен: соседние поля query могут резолвиться параллельно, а поля верхнего уровня mutation выполняются последовательно.
- Ошибки содержат path; упавшее поле становится null.
- Non-null поля передают null вверх до ближайшего nullable-родителя.
- Делайте nullable поля, которые обращаются к ненадёжным зависимостям.
07Анализ стоимости и пагинация в масштабе
Одних ограничений глубины мало: неглубокий запрос на 100 товаров, у каждого по 100 отзывов, у каждого по 100 авторов, может запросить миллион объектов. Статический cost analysis назначает стоимость каждому полю, умножает списочные поля на запрошенный размер страницы и отклоняет запросы, превышающие бюджет, ещё до выполнения. Публикуйте стоимость в extension ответа, чтобы клиенты её видели, и ограничивайте скорость по стоимости в минуту. Сделайте пагинацию обязательной с максимальным размером страницы. Паттерн connections на курсорах (edges, node, pageInfo с endCursor и hasNextPage) — стандарт де-факто: курсоры непрозрачны, устойчивы к вставкам и позволяют избежать глубоких OFFSET-сканов, замедляющих базы. Кодируйте в курсоре ключ сортировки и ID, а под капотом используйте keyset-запрос (WHERE (created_at, id) < (...) ORDER BY created_at DESC, id DESC).
type ProductConnection {
edges: [ProductEdge!]!
pageInfo: PageInfo!
}
type ProductEdge {
cursor: String!
node: Product!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
type Query {
products(first: Int! = 20, after: String): ProductConnection!
}
08Federation: один граф из многих сервисов
Federation позволяет нескольким командам владеть каждая своим subgraph, а клиенты видят один supergraph. Subgraph объявляет сущности с ключом, например Product, идентифицируемый по id. Другой subgraph может расширить эту сущность своими полями, например reviews, и предоставляет reference resolver, который загружает дополнительные данные товара, имея только его ключ. Router собирает схемы subgraph, планирует каждый входящий запрос, вызывает subgraph в правильном порядке, часто параллельно, и склеивает результаты. Это сохраняет автономию команд и даёт клиентам единый API. Цена: проверки composition в CI становятся критичными, потому что один subgraph может сломать supergraph; планы запросов могут разворачиваться во множество внутренних вызовов, поэтому entity resolver обязаны батчить; а отладка требует трейсинга через router. Это подходит организациям с несколькими бэкенд-командами; для одной команды это лишние накладные расходы.
09Ошибки как данные, дизайн input и новые возможности спецификации
Используйте массив errors для неожиданных сбоев, а ожидаемые бизнес-исходы моделируйте в схеме. Mutation может возвращать union, например PlaceOrderResult = OrderPlaced | OutOfStock | PaymentDeclined, а клиенты обрабатывают каждый случай через fragment, получая типизированные поля для каждого исхода. Проектируйте input под каждое действие: placeOrder(input: PlaceOrderInput!) оставляет аргументы расширяемыми. Редакция спецификации GraphQL от сентября 2025 года, первая с 2021-го, добавила OneOf input-объекты, где должно быть передано ровно одно поле, — это аккуратно моделирует «найти товар по id или по sku». Incremental delivery с @defer и @stream, позволяющая медленным частям ответа приходить позже, поддерживается рядом серверов и клиентов, но всё ещё проходит процесс спецификации, поэтому проверьте поддержку в своём стеке, прежде чем на неё полагаться.
union PlaceOrderResult = OrderPlaced | OutOfStock | PaymentDeclined
type OrderPlaced { order: Order! }
type OutOfStock { productIds: [ID!]! }
type PaymentDeclined { reason: String! }
input ProductLookup @oneOf {
id: ID
sku: String
}
type Mutation {
placeOrder(input: PlaceOrderInput!): PlaceOrderResult!
}
type Query {
product(by: ProductLookup!): Product
}
Реальные ситуации
Страница товара порождает 400 запросов к базе
Проблема
Новый экран запрашивает товары с категориями, вариантами и отзывами. Мониторинг показывает сотни запросов на один request и p95 latency больше двух секунд.
Как решить
Добавьте трейсинг по резолверам, чтобы увидеть, какие поля разворачиваются. Введите DataLoader, создаваемые на каждый request в context, для каждой связи: категория по ID, варианты по product ID, отзывы по product ID (возвращая массивы, сгруппированные по ключу). Проверьте, что списочные поля пагинируются с разумным значением по умолчанию. Заново измерьте число запросов и latency по operation name. Добавьте тест в CI, который выполняет основные операции на базе с тестовыми данными и падает, если число запросов превышает порог, — так регрессия не вернётся незаметно.
Злоупотребляющие запросы к публичному эндпоинту
Проблема
Кто-то отправляет глубоко вложенные запросы, из-за которых API падает по таймауту для всех. API используют только собственные веб- и мобильные приложения компании.
Как решить
Раз им пользуются только first-party клиенты, переходите на persisted queries: извлекайте операции при сборке, регистрируйте их хэши и в продакшене отклоняйте любую операцию, которой нет в allow-list. Пока это не выкатили, добавьте ограничения глубины и стоимости, максимальные размеры страниц и таймаут выполнения, а также rate limit по стоимости для каждого клиента. Отключите подсказки полей в сообщениях об ошибках для неизвестных полей. Логируйте отклонённые запросы с идентификаторами клиентов, чтобы понять, злоупотребление это или баг в вашем же приложении.
Удаление широко используемого поля
Проблема
Поле Product.price — целое число в центах, а команда хочет тип Money с валютой. Старые версии мобильного приложения будут использоваться ещё месяцами.
Как решить
Добавьте рядом со старым новое поле priceMoney: Money! и пометьте price через @deprecated(reason: "Use priceMoney"). Выпустите клиенты, использующие новое поле. Отслеживайте использование price по operation name и версии клиента в метриках сервера или schema registry. Держите оба поля, пока использование старого не упадёт до согласованного уровня, учитывая политику принудительного обновления мобильного приложения. Затем удалите его, а проверка схемы в CI подтвердит, что ни одна зарегистрированная операция его больше не запрашивает.
Шпаргалка
- Пометить поле устаревшим
price: Int @deprecated(reason: "Use priceMoney")- Аргументы курсорной пагинации
products(first: Int! = 20, after: String): ProductConnection!- Loader на каждый request
context: () => ({ loaders: createLoaders(db) })- Типизированные бизнес-ошибки
union PlaceOrderResult = OrderPlaced | OutOfStock- Ровно одно поле input
input ProductLookup @oneOf { id: ID sku: String }- Запрос persisted query (APQ)
extensions: { persistedQuery: { version: 1, sha256Hash: "..." } }- Переиспользование выборки полей
fragment ProductCard on Product { id name price }- Ключ federated-сущности
type Product @key(fields: "id") { id: ID! }- Изучить схему
query { __schema { types { name } } }
Вопросы и ответы
Q1Какую проблему решает GraphQL по сравнению с REST?
Он решает проблемы over-fetching и under-fetching. С фиксированными REST-эндпоинтами экран часто получает поля, которые ему не нужны, или вынужден вызывать несколько эндпоинтов, чтобы собрать свои данные. GraphQL позволяет клиенту за один round-trip запросить ровно те поля и связи, которые ему нужны, с проверкой по типизированной схеме. Больше всего это помогает, когда одну предметную область потребляет множество разных клиентов. Обратная сторона — сложность переезжает на сервер: батчинг доступа к данным, контроль стоимости запросов и замена HTTP-кэширования клиентскими кэшами или persisted queries.
Q2Что такое проблема N+1 и как её решить?
Поскольку у каждого поля свой резолвер, выборка списка из N элементов, а затем связанного объекта для каждого элемента порождает один запрос за списком плюс N запросов за связями. С вложенностью это умножается. Стандартное решение — DataLoader: резолверы запрашивают ключи у loader'а, который собирает все ключи, запрошенные в одном такте, загружает их одним батч-запросом и возвращает результаты по порядку. Он также кэширует в пределах запроса. Loader'ы нужно создавать на каждый запрос, чтобы данные не утекали между пользователями. Некоторые фреймворки в качестве альтернатив предлагают также планирование запросов или lookahead с join'ами.
Q3Как защитить GraphQL API?
В несколько слоёв. Аутентификация — на точке входа, а авторизация — в бизнес-слое или на уровне полей, потому что один запрос может добраться до множества типов. Лимиты против дорогих запросов: максимальная глубина, оценка сложности или стоимости, обязательная пагинация с максимальным размером страницы и таймауты выполнения. Rate limiting по стоимости запроса, а не по их числу. Для собственных клиентов persisted queries работают как allow-list, так что произвольные запросы отклоняются. Introspection можно отключить для приватных API, хотя это сокрытие, а не защита. И обычные правила по-прежнему действуют: валидировать ввод, не допускать инъекций в резолверах и логировать ошибки, не раскрывая внутренности.
Q4Как кэшировать ответы GraphQL?
HTTP-кэширование сложнее, потому что запросы обычно — это POST на один эндпоинт с разными телами. На клиенте нормализованные кэши вроде Apollo Client или Relay хранят объекты по типу и ID и обновляют их по результатам мутаций. Persisted queries позволяют клиентам отправлять хэш через GET, что снова делает возможным кэширование в CDN и браузере для публичных данных. На сервере вы кэшируете дорогие результаты резолверов или вызовы к вышестоящим сервисам в Redis, аккуратно включая в ключ пользователя или tenant'а, если данные приватные. Некоторые серверы поддерживают подсказки кэширования на уровне полей, чтобы вычислять безопасную политику кэширования всего ответа.
Q5Когда вы выберете tRPC вместо GraphQL или REST?
Когда одна и та же команда владеет TypeScript-бэкендом и TypeScript-клиентами в одном репозитории, например Next.js-приложением с его API. tRPC даёт сквозную типобезопасность почти без церемоний: вы определяете процедуры на сервере и вызываете их с клиента как функции, а ошибки типов видны сразу. Нет ни языка схем, ни генерации кода. Он не подходит для публичных API, потребителей не на TypeScript или множества независимых команд, потому что контракт — это типы TypeScript, а не независимая от языка схема. Для таких случаев я бы выбрал REST с OpenAPI или GraphQL.
Q6Как развивать схему GraphQL без версионирования?
GraphQL поощряет непрерывное эволюционное развитие добавлением вместо эндпоинтов v1 и v2. Добавлять новые типы и поля безопасно, потому что существующие клиенты их не запрашивают. Чтобы изменить поле, добавьте новое, пометьте старое @deprecated с причиной и через отслеживание операций следите за использованием поля, пока его не перестанет использовать ни один клиент, — тогда удаляйте. Никогда не меняйте смысл поля и не делайте nullable-аргумент обязательным без переходного периода. Проверки схемы в CI, которые сравнивают предлагаемую схему с продакшен-трафиком, ловят ломающие изменения до выкатки.
Вопросы уровня senior
S1Как бы вы внедрили GraphQL в организации с существующими REST-сервисами?
Постепенно, как слой для клиентов, а не переписыванием. Начните с одной продуктовой поверхности, обычно мобильного приложения, страдающего от множества round-trip, и постройте GraphQL-шлюз, резолверы которого вызывают существующие REST-сервисы, с DataLoader для батчинга вызовов и кэшем перед медленными. Проектируйте схему под потребности клиентов, а не по форме текущих эндпоинтов. С первого дня внедрите persisted queries, лимиты стоимости, трейсинг и проверки схемы. Если пилот снизит сложность клиента и latency, расширяйтесь по доменам; когда начнут участвовать несколько бэкенд-команд, рассмотрите federation, чтобы каждая владела своим subgraph. Компромиссы: ещё один hop и рантайм в эксплуатации, пробел в навыках команды и соблазн перенести бизнес-логику в резолверы, хотя она должна оставаться в сервисах.
S2Как сделать авторизацию на уровне полей, не превращая резолверы в кашу?
Держите авторизацию в доменном слое, а GraphQL-слой пусть только вызывает её. Резолверы получают данные через сервисы, которые уже знают текущего пользователя или тенанта из context и применяют те же политики, что и REST-эндпоинты, поэтому правила живут в одном месте. Для правил на уровне полей, например себестоимость видна только менеджерам, используйте schema directive или middleware, которые сопоставляют поля с названиями прав и проверяют их до резолва, возвращая null с ошибкой или типизированный результат «нет доступа». Убедитесь, что batch loader тоже применяют ограничение области, поскольку они обходят резолверы отдельных объектов. Тестируйте операциями, выполненными под разными ролями. Компромисс: декларативные директивы аккуратны, но могут прятать логику, поэтому сложные правила, зависящие от данных, держите в сервисах, а директивы используйте для простых проверок ролей.
S3Нормализованный кэш в стиле Apollo или простое кэширование запросов на клиенте: когда что лучше?
Нормализованный кэш хранит каждый объект один раз по типу и ID, поэтому mutation, возвращающая обновлённый товар, мгновенно обновляет все экраны, где он показан, а пересекающиеся запросы можно обслужить без сетевого вызова. Он хорош в богатых интерактивных приложениях со множеством представлений одних и тех же сущностей. Его цена — сложность: нужно обеспечить стабильные ID, писать логику обновления кэша для вставок и удалений в списках и отлаживать устаревшие записи. Кэширование на уровне запроса с ключом по query и variables, как в React Query или document cache в urql, проще и предсказуемее: после mutation делайте refetch или invalidate по запросу. Оно подходит приложениям с преимущественно читающими независимыми экранами. Я выбираю нормализацию, когда одни и те же сущности редактируются и показываются во многих представлениях, а в остальных случаях — простое кэширование.
Глоссарий
- Schema
- Типизированное описание всех типов, полей и операций, которые предоставляет GraphQL API.
- Резолвер
- Функция, возвращающая значение одного поля типа.
- DataLoader
- Утилита, которая батчит и кэширует поиск по ключам в рамках одного запроса.
- Fragment
- Переиспользуемая выборка полей типа, общая для запросов и компонентов.
- Persisted query
- Заранее зарегистрированная операция, которую клиент вызывает по ID или хэшу вместо отправки полного текста.
- Connection
- Паттерн курсорной пагинации с edges, node и pageInfo.
- Introspection
- Запросы, позволяющие клиентам узнавать типы и поля схемы во время выполнения.
- Federation
- Объединение нескольких независимо принадлежащих subgraph в один supergraph за router.
- Null propagation
- Ошибка в non-null поле обнуляет в ответе его ближайшего nullable-родителя.
Что изучить дальше
Есть идея продукта?Давайте создадим его.
Расскажите о своей идее, продукте или команде. Обычно отвечаю в течение одного рабочего дня.