Перейти к основному содержимому

GraphQL

Разработчику Архитектору Аналитику

GraphQL

Основы

GraphQL — язык запросов к API и среда на сервере, которая эти запросы выполняет. Клиент в тексте запроса перечисляет нужные поля; сервер возвращает JSON ровно такой формы — без лишних данных.

В классическом REST у каждого ресурса свой URL (/users, /orders/42), а состав ответа задаёт сервер. В типичном GraphQL-приложении один HTTP-адрес (часто POST /graphql) обслуживает разные запросы. Сервер публикует схему типов — список сущностей и полей, по которому клиент и IDE подсказывают автодополнение.

Спецификация — graphql.org. Вводная на MDN — GraphQL.

GraphQL часто берут, когда:

  • мобильному клиенту нужен короткий ответ без лишних полей
  • один запрос собирает данные из нескольких источников (меньше round-trip к серверу)
  • много разных экранов и у каждого свой набор полей
  • слой BFF (Backend For Frontend) склеивает микросервисы

Контракты REST — JSON Schema и OpenAPI. Обмен данными — JSON, интеграции.


Схема (SDL)

SDL (Schema Definition Language) — язык описания типов GraphQL. Схема похожа на контракт OpenAPI, но мыслит объектами и связями, а URL.

type Query {
user(id: ID!): User
users(limit: Int = 10, offset: Int = 0): [User!]!
order(id: ID!): Order
}

type Mutation {
createOrder(input: CreateOrderInput!): Order!
cancelOrder(id: ID!): Order!
}

type Subscription {
orderStatusChanged(orderId: ID!): Order!
}

type User {
id: ID!
email: String!
name: String
orders(first: Int = 10, after: String): OrderConnection!
createdAt: String!
}

type Order {
id: ID!
total: Float!
status: OrderStatus!
createdAt: String!
items: [OrderItem!]!
}

type OrderItem {
productId: ID!
title: String!
quantity: Int!
price: Float!
}

enum OrderStatus {
PENDING
PAID
SHIPPED
CANCELLED
}

input CreateOrderInput {
userId: ID!
items: [OrderItemInput!]!
}

input OrderItemInput {
productId: ID!
quantity: Int!
}

type OrderConnection {
edges: [OrderEdge!]!
pageInfo: PageInfo!
}

type OrderEdge {
cursor: String!
node: Order!
}

type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}

Обозначения в типах:

  • ! после типа — поле обязательно, не может быть null
  • [User!]! — список пользователей без null внутри; сам список тоже не null
  • ID, String, Int, Float, Boolean — встроенные скалярные типы
  • Query — точка входа для чтения
  • Mutation — для изменений
  • Subscription — для потоковых обновлений
  • input — типы только для аргументов мутаций
  • enum — фиксированный набор строковых значений

Резолвер — функция на сервере, которая по полю схемы достаёт данные (часто из SQL или ORM).


Запрос query

Клиент запрашивает только перечисленные поля:

query GetUserWithOrders {
user(id: "42") {
email
name
orders(first: 3) {
edges {
node {
id
total
status
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}

Ответ в JSON той же вложенности:

{
"data": {
"user": {
"email": "ivan@example.com",
"name": "Иван",
"orders": {
"edges": [
{
"node": {
"id": "5001",
"total": 1990.0,
"status": "PAID"
}
}
],
"pageInfo": {
"hasNextPage": true,
"endCursor": "YXJyYXljb25uZWN0aW9uOjM="
}
}
}
}
}

Запрос уходит HTTP-методом POST с телом:

{
"query": "query GetUserWithOrders { user(id: \"42\") { email } }",
"operationName": "GetUserWithOrders"
}

Некоторые серверы разрешают GET с query в query-string для кэширования — редко на проде из-за лимита длины URL.


Переменные

Жёстко прошитые литералы в строке запроса неудобны для приложения. Variables передают снаружи:

query GetUser($userId: ID!, $orderLimit: Int = 5) {
user(id: $userId) {
email
orders(first: $orderLimit) {
edges {
node {
id
total
}
}
}
}
}

Тело HTTP:

{
"query": "query GetUser($userId: ID!, $orderLimit: Int = 5) { ... }",
"operationName": "GetUser",
"variables": {
"userId": "42",
"orderLimit": 3
}
}

Правила переменных:

  • имя в запросе с $: $userId
  • тип обязателен: $userId: ID!
  • значение по умолчанию только у необязательных: $limit: Int = 10
  • variables в JSON — обычный объект, не строка

Ошибка Variable $id was not provided — забыли ключ в variables или передали null для ID!.


Фрагменты

Fragment — переиспользуемый набор полей для одного типа:

fragment OrderSummary on Order {
id
total
status
createdAt
}

query UserOrders($id: ID!) {
user(id: $id) {
email
orders(first: 10) {
edges {
node {
...OrderSummary
items {
title
quantity
}
}
}
}
}
}

Inline fragment — условные поля по типу интерфейса или union:

query Search($text: String!) {
search(text: $text) {
... on User {
email
}
... on Product {
title
price
}
}
}

Фрагменты уменьшают дублирование в клиентском коде (Apollo Client кэширует по фрагментам).


Псевдонимы (aliases)

Если нужно дважды вызвать одно поле с разными аргументами:

query CompareUsers {
alice: user(id: "1") {
email
name
}
bob: user(id: "2") {
email
name
}
}

Ответ:

{
"data": {
"alice": { "email": "alice@example.com", "name": "Алиса" },
"bob": { "email": "bob@example.com", "name": "Боб" }
}
}

Без alias второй user перезаписал бы первый в объекте data.


Мутации

Mutation меняет данные — аналог POST или PATCH в REST:

mutation CreateOrder($input: CreateOrderInput!) {
createOrder(input: $input) {
id
total
status
items {
productId
quantity
}
}
}

Переменные:

{
"input": {
"userId": "42",
"items": [
{ "productId": "99", "quantity": 2 }
]
}
}

Рекомендации:

  • мутации называют глаголом: createOrder, updateProfile
  • возвращайте изменённый объект — клиент обновит кэш без второго query
  • для идемпотентности используйте клиентский idempotencyKey в input, если сервер поддерживает

Интроспекция

GraphQL-схема сама доступна через GraphQL. Запрос introspection возвращает список типов, полей и аргументов.

query IntrospectOrderType {
__type(name: "Order") {
name
fields {
name
type {
name
kind
}
}
}
}

Зачем это нужно:

  • GraphiQL и Apollo Studio рисуют документацию из introspection
  • codegen генерирует TypeScript-типы из схемы
  • IDE подсказывает поля при написании query

На продакшене introspection часто отключают — схема раскрывает внутреннюю структуру. На staging оставляют для разработчиков.

Системные поля __schema и __type — часть спецификации, не пишите их в публичной SDL вручную.


Проблема N+1 и DataLoader

Наивные резолверы вызывают базу на каждое поле каждого объекта. Один GraphQL-запрос списка пользователей с заказами превращается в сотни SQL-запросов — проблема N+1.

DataLoader — библиотека, которая батчит и кэширует загрузку за один tick event loop:

import DataLoader from "dataloader";

const orderLoader = new DataLoader(async (userIds) => {
const orders = await db.orders.findByUserIds(userIds);
const grouped = groupByUserId(orders);
return userIds.map((id) => grouped[id] ?? []);
});

// В резолвере User.orders:
orders: (user) => orderLoader.load(user.id),

Один вызов load для многих пользователей → один SQL WHERE user_id IN (...).

Альтернативы:

  • JOIN в одном запросе на уровне root resolver
  • lookahead — анализ AST запроса до выполнения
  • ORM с eager loading, если GraphQL-запрос предсказуем

Симптом N+1 в логах — десятки одинаковых SELECT подряд при одном HTTP-запросе к /graphql.


Аутентификация (JWT)

GraphQL не задаёт свой способ auth. Обычно используют тот же JWT в заголовке, что и в OpenAPI bearerAuth:

POST /graphql HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Content-Type: application/json

Сервер на каждый запрос:

  1. проверяет токен
  2. кладёт userId в context
  3. резолверы читают context и фильтруют данные
const server = new ApolloServer({
typeDefs,
resolvers,
context: async ({ req }) => {
const token = req.headers.authorization?.replace("Bearer ", "");
const user = await verifyJwt(token);
return { user, db };
},
});

Ошибки auth:

  • HTTP 401 — нет токена или токен невалиден (до выполнения GraphQL)
  • GraphQL errors с extensions.code: UNAUTHENTICATED — токен есть, но прав на поле нет

Для публичных и приватных полей в одной схеме — проверка прав в резолвере, не только на HTTP-уровне.


Пагинация — курсоры Relay

Offset (limit / offset) ломается при частых вставках: страница 2 после добавления строки показывает дубликаты. Cursor-based пагинация стабильнее.

Спецификация Relay Cursor Connections:

query OrdersPage($userId: ID!, $after: String) {
user(id: $userId) {
orders(first: 10, after: $after) {
edges {
cursor
node {
id
total
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
  • first — сколько элементов взять
  • after — курсор с прошлой страницы (endCursor)
  • edges[].cursor — непрозрачная строка (часто base64)
  • pageInfo.hasNextPage — есть ли ещё данные

Сравнение с OpenAPI offset-пагинацией:

ПодходGraphQL RelayREST query page/pageSize
Стабильность при вставкахлучшехуже на больших таблицах
Сложность клиентавышениже
Кэш CDNсложнеепроще для GET

Подписки (subscriptions)

Subscription — долгоживущий канал обновлений (статус заказа, чат, уведомления). Транспорт — чаще WebSocket, реже SSE.

Схема:

type Subscription {
orderStatusChanged(orderId: ID!): Order!
}

Клиент:

subscription WatchOrder($id: ID!) {
orderStatusChanged(orderId: $id) {
id
status
}
}

Сервер пушит новые объекты Order при смене статуса.

Ограничения:

  • сложнее масштабировать за одним инстансом (нужен pub/sub — Redis, Kafka)
  • не все хостинги разрешают WebSocket
  • для редких опросов хватает polling REST

Обзор достаточен на старте; углубляться после уверенных query и mutation.


Федерация (кратко)

В больших компаниях схему делят между командами. Apollo Federation (и аналоги) позволяют собрать один публичный GraphQL из нескольких микросервисов:

  • сервис users объявляет type User @key(fields: "id")
  • сервис orders расширяет extend type User @key(fields: "id") { orders: [Order!]! }
  • gateway маршрутизирует части запроса в нужный сервис

Связь с микросервисами — федерация это способ склеить BFF без монолитной схемы в одном репозитории.

Минусы: операционная сложность, отладка запроса через несколько сетевых прыжков.


Обработка ошибок

Ответ GraphQL всегда HTTP 200 часто, даже при ошибках — проверяйте и errors, и data.

Частичный успех:

{
"data": {
"user": {
"email": "ivan@example.com",
"orders": null
}
},
"errors": [
{
"message": "Access denied to orders",
"path": ["user", "orders"],
"extensions": {
"code": "FORBIDDEN"
}
}
]
}

Типы ситуаций:

УровеньПримерЧто делать клиенту
HTTP 4xx/5xx401, 502Повтор auth / показать "сервер недоступен"
GraphQL errorsполе не найдено в схемеИсправить запрос
GraphQL errors + dataодно поле упалоПоказать остальное, пометить ошибку
Бизнес-ошибка в mutationuserError в payloadОбработать как в REST 422

В мутациях паттерн union payload:

union CreateOrderResult = Order | ValidationError

type ValidationError {
message: String!
field: String
}

type Mutation {
createOrder(input: CreateOrderInput!): CreateOrderResult!
}

Клиент через inline fragment выбирает ветку — типобезопаснее, чем только errors в массиве.


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

Открытый GraphQL без лимитов уязвим к тяжёлым вложенным запросам:

query Attack {
users {
friends {
friends {
friends {
email
}
}
}
}
}

Защита:

  • max depth — глубина вложенности полей (например 7)
  • complexity score — у каждого поля вес, сумма не выше порога
  • rate limiting по IP или токену (как HTTP 429 в OpenAPI)
  • timeout на выполнение запроса
  • persisted queries — клиент шлёт только hash известного запроса

Настройки задают на gateway или в Apollo Server / Hot Chocolate / Strawberry.


Apollo Server, Hot Chocolate, Strawberry

РеализацияСтекОсобенности
Apollo ServerNode.jsде-факто стандарт, Federation, Studio
GraphQL YogaNode, универсальныйлёгкий, современный middleware
Hot Chocolate.NETинтеграция ASP.NET, проекции, paging
StrawberryPythontype hints, dataclasses, FastAPI
GraphenePythonстарше Strawberry, ещё встречается

Минимальный Strawberry + FastAPI:

import strawberry
from fastapi import FastAPI
from strawberry.fastapi import GraphQLRouter

@strawberry.type
class User:
id: strawberry.ID
email: str

@strawberry.type
class Query:
@strawberry.field
def user(self, id: strawberry.ID) -> User | None:
return User(id=id, email="demo@example.com")

schema = strawberry.Schema(query=Query)
app = FastAPI()
app.include_router(GraphQLRouter(schema), prefix="/graphql")

Hot Chocolate в ASP.NET:

builder.Services
.AddGraphQLServer()
.AddQueryType<Query>()
.AddProjections()
.AddFiltering();

Выбор сервера обычно следует за стеком backend; контракт SDL остаётся переносимым между языками.


Тестирование с GraphiQL

GraphiQL — IDE в браузере для запросов к живому endpoint:

  • автодополнение из introspection
  • панель Variables (JSON)
  • история запросов
  • Docs explorer по типам

Где найти:

  • /graphql в Apollo Server и Strawberry по умолчанию на dev
  • расширение GraphiQL в VS Code
  • Postman, Insomnia — вкладка GraphQL

Чек-лист теста вручную:

  1. выполнить query с variables
  2. mutation с невалидным input — ожидать errors или ValidationError
  3. запрос без токена на защищённое поле — 401 или FORBIDDEN
  4. глубокий вложенный query — отклонение по complexity

Автотесты — HTTP-клиент + assert на data и errors (pytest, Jest). Контракт SDL можно проверять graphql-schema-linter.

Связь с отладкой — логируйте operationName и время резолверов.


Паттерн BFF

BFF (Backend For Frontend) — тонкий backend под конкретный клиент (iOS, web, admin). GraphQL хорошо ложится на BFF:

  • мобильному клиенту — короткий query без лишних полей admin-панели
  • BFF агрегирует три REST-вызова в один GraphQL-запрос
  • внутренние сервисы остаются на OpenAPI, наружу только GraphQL

Риск: BFF превращается в монолит — держите резолверы тонкими, бизнес-логику в доменных сервисах.


Сравнение с REST и OpenAPI

КритерийREST + OpenAPIGraphQL
Адресамного URLобычно один /graphql
Состав ответазадаёт сервер (DTO)выбирает клиент в запросе
Кэш браузера и CDNпроще по URL и GETсложнее; нужен клиент с нормализацией
Версии API/v1, /v2эволюция схемы, @deprecated
ДокументацияSwagger UI, RedocGraphiQL, Apollo Studio
Нагрузка на серверпредсказуемые endpointриск тяжёлых запросов — нужны лимиты
Загрузка файловотдельный multipart endpointмутация + upload scalar (сложнее)
Пагинацияpage, offset, cursor в queryRelay connections, единый стиль в схеме
ОшибкиHTTP-код + телоHTTP + массив errors + частичный data
Типизация клиентаopenapi-generatorgraphql-codegen
ПодпискиWebSocket отдельноSubscription в схеме
Сборка из микросервисовAPI Gateway + RESTFederation, BFF
Обучение командыниже порогвыше порог (SDL, резолверы, N+1)
ИдемпотентностьPUT, DELETE по смыслу HTTPдоговорённость в mutation design

Когда проще REST:

  • публичный CRUD с кэшированием CDN
  • загрузка бинарных файлов отдельными endpoint
  • команда уже стандартизировалась на OpenAPI
  • партнёры ждут простые curl-примеры по URL

Когда уместен GraphQL:

  • много клиентских экранов с разным набором полей
  • слой BFF над микросервисами
  • агрегация данных за один round-trip
  • мобильный трафик дорог — экономия байт в ответе

Оба подхода могут сосуществовать: публичный REST по OpenAPI, внутренний GraphQL для своих приложений.


Полный пример сценария

Сценарий: авторизованный пользователь создаёт заказ и смотрит список.

Шаг 1 — query текущего пользователя

query Me {
me {
id
email
orders(first: 5) {
edges {
node {
id
status
total
}
}
}
}
}

Шаг 2 — mutation с variables

mutation PlaceOrder($input: CreateOrderInput!) {
createOrder(input: $input) {
... on Order {
id
status
total
}
... on ValidationError {
message
field
}
}
}

Шаг 3 — subscription (опционально)

subscription OnOrderPaid($id: ID!) {
orderStatusChanged(orderId: $id) {
id
status
}
}

Цепочка данных:


Работа с SQL и ORM

Резолверы редко пишут SQL вручную в каждом поле — используют ORM:

# Псевдокод Strawberry + SQLAlchemy
def resolve_orders(self, user: User, info, first: int, after: str | None):
qs = db.session.query(Order).filter(Order.user_id == user.id)
if after:
qs = qs.filter(Order.cursor > decode_cursor(after))
return qs.limit(first).all()

Правила:

  • один root query — один осмысленный запрос к БД где возможно
  • вложенные поля — DataLoader или JOIN
  • индексы на user_id, поля курсора
  • N+1 смотрите в логах SQL

GraphQL не заменяет SQL — он слой над источником данных.


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

СимптомПричина
Cannot query field XПоля нет в схеме или опечатка в имени
Variable $id was not providedНе передали обязательный аргумент
Variable $id got invalid valueТип в JSON не совпадает с SDL ("42" для Int без кавычек в variables)
Сотни SQL-запросов на один GraphQL-запросРезолвер на каждое поле без batch (DataLoader)
Сервер падает от одного запросаНет лимита глубины и сложности query
Пустой data и HTTP 401Токен не передан — проверьте заголовок
Дубли на второй странице offsetПерешли на cursor pagination
Introspection пустая на prodОтключена политикой — используйте staging schema

Когда GraphQL не подходит

  • публичное API для партнёров без SDK — OpenAPI и curl проще
  • жёсткое CDN-кэширование GET-ресурсов
  • команда без опыта и жёсткие сроки — REST быстрее стартовать
  • весь backend — один простой CRUD без агрегации
  • критична минимальная поверхность атаки — GraphQL introspection и гибкие запросы требуют дисциплины

Директива @deprecated

Схема GraphQL эволюционирует без /v2 в URL. Устаревшие поля помечают @deprecated:

type User {
id: ID!
email: String!
login: String @deprecated(reason: "Используйте email как идентификатор")
}

GraphiQL зачёркивает поле и показывает reason. Клиенты codegen получают предупреждение при компиляции. Удаление поля — только после метрик: кто ещё запрашивает login.

Сравнение с версиями в OpenAPI: там часто дублируют path /v1/users и /v2/users. В GraphQL один тип User живёт дольше, меняется постепенно.


Клиентский кэш (Apollo Client)

Сервер отдаёт дерево JSON; клиент нормализует его в кэш по id:

// Упрощённая идея Apollo InMemoryCache
{
"User:42": { id: "42", email: "ivan@example.com" },
"Order:5001": { id: "5001", total: 1990, __typename: "Order" }
}

Повторный query с пересекающимися полями не дублирует данные в памяти. Мутация createOrder обновляет кэш через refetchQueries или update функцию.

Почему REST проще кэшировать на CDN:

  • GET /products/42 — один URL, один ответ
  • POST /graphql с разным телом query — CDN не знает, что кэшировать без дополнительной настройки

Для публичного read-only контента GraphQL реже выигрывает у REST с CDN.


Настройка лимитов — пример Apollo Server

import depthLimit from "graphql-depth-limit";
import { createComplexityLimitRule } from "graphql-validation-complexity";

const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(7),
createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 2,
listFactor: 10,
}),
],
});

Порог подбирают по профилю реальных запросов мобильного и web-клиента. Слишком низкий — легитимные экраны ломаются; слишком высокий — окно для DoS остаётся.

Логируйте отклонённые запросы с operationName — так видно, какой экран нужно упростить.


JWT — полный цикл на клиенте

async function graphqlFetch(query, variables) {
const token = localStorage.getItem("accessToken");
const response = await fetch("/graphql", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ query, variables }),
});

if (response.status === 401) {
// refresh token flow или редирект на login
throw new Error("unauthorized");
}

const json = await response.json();
if (json.errors?.length) {
console.error(json.errors);
}
return json;
}

Refresh token обычно остаётся на отдельном REST endpoint — OpenAPI удобнее описывает POST /auth/refresh, чем смешивать с GraphQL mutation.


Тест в pytest — пример

import httpx

QUERY = """
query GetUser($id: ID!) {
user(id: $id) { email }
}
"""

def test_get_user_returns_email(graphql_url, auth_headers):
payload = {
"query": QUERY,
"variables": {"id": "42"},
}
r = httpx.post(graphql_url, json=payload, headers=auth_headers)
assert r.status_code == 200
body = r.json()
assert "errors" not in body or body["errors"] is None
assert body["data"]["user"]["email"] == "ivan@example.com"

Для mutation добавьте проверку побочного эффекта в SQL — строка заказа появилась в таблице.


Federation — схема из двух сервисов

Сервис users:

type User @key(fields: "id") {
id: ID!
email: String!
}

Сервис orders:

extend type User @key(fields: "id") {
id: ID! @external
orders: [Order!]!
}

type Order {
id: ID!
total: Float!
}

Gateway объединяет типы. Клиент видит один GraphQL, запрос:

query {
user(id: "1") {
email
orders { id total }
}
}

Gateway запрашивает email у users, orders у orders. Отладка сложнее монолита — trace id на весь запрос обязателен.


Сравнение инструментов отладки

ИнструментСильная сторона
GraphiQLбыстрый ручной query на dev
Apollo Studioсхема, метрики, trace
Postmanколлекции для QA, окружения
graphql-codegenтипы TypeScript из SDL
SpectaQLстатическая HTML-документация из схемы

Документация REST — Swagger UI. Обе стороны должны иметь один источник правды — SDL или OpenAPI в git.


Интеграция с микросервисной архитектурой

Типичные роли:

  • Публичный REST — партнёры, webhook, простые интеграции (интеграции)
  • Внутренний GraphQL BFF — свои приложения
  • gRPC / очереди — между сервисами без HTTP JSON

Не обязательно выбирать один стиль на всю компанию — выбирают на границе системы.


Практикум — упражнения

Уровень 1 — запросы

  1. По SDL выше напишите query только с user.email и orders.edges.node.id.
  2. Перепишите с variables $userId и $first.
  3. Добавьте alias primary и secondary для двух вызовов user с разными id.

Уровень 2 — фрагменты

  1. Вынесите поля заказа в fragment OrderCard on Order.
  2. Используйте фрагмент в query и mutation ответа createOrder.

Уровень 3 — пагинация

  1. Выполните два последовательных query с after: endCursor из первого ответа.
  2. Объясните, почему при offset это могло бы дать дубликат.

Уровень 4 — auth и ошибки

  1. Отправьте запрос к me без заголовка Authorization — зафиксируйте HTTP-код и тело.
  2. Отправьте mutation с пустым items — получите ValidationError или errors.

Уровень 5 — производительность

  1. Включите лог SQL и выполните query users { orders { items { title } } } без DataLoader — посчитайте запросы.
  2. Добавьте DataLoader и сравните число SQL.

Уровень 6 — сравнение с REST

  1. Опишите тот же сценарий createOrder в фрагменте OpenAPI — сколько HTTP-вызовов понадобилось бы в REST для экрана checkout.
  2. Составьте таблицу полей, которые лишние для мобильного клиента в REST DTO, но не придут в GraphQL query.

Разбор — в паре с JSON Schema практикумом.


Шпаргалка

ЗадачаМеханизм GraphQL
Меньше полей в ответеquery с перечислением полей
Параметры с клиентаvariables
Переиспользование полейfragments
Два вызова одного поляaliases
Документация схемыintrospection + GraphiQL
Много SQL на один запросDataLoader
JWTзаголовок Authorization
Стабильные страницы спискаRelay cursors
Live обновленияsubscriptions
Несколько микросервисовfederation или BFF
Лимит тяжёлых querydepth / complexity / rate limit

Связанные материалы