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

GraphQL: схема, запросы и мутации

Коротко

GraphQL-сервер состоит из схемы и резолверов. Схема на языке SDL описывает типы, точки входа Query и Mutation и аргументы, а резолверы — функции, которые возвращают значения полей из базы или других сервисов. Клиент отправляет POST /graphql с текстом операции и переменными и получает JSON той же формы. Чтобы сервер выдержал произвольные запросы, связи загружают пакетами через DataLoader, а глубину и стоимость запросов ограничивают до выполнения.

Пошаговый урокУровень: СреднийGraphQLПрактика: ~1,5–3 ч

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

  • описать схему SDL с типами, Query, Mutation и input-типом и поднять по ней сервер на GraphQL Yoga
  • написать резолверы с аргументами и контекстом и выполнить запрос и мутацию с переменными через curl и fetch
  • найти N+1 по счётчику запросов к базе и устранить его через DataLoader
  • вернуть ожидаемую ошибку с extensions.code, скрыть неожиданную и ограничить глубину запроса

Проверено на

Node.js 24 LTS (24.18)GraphQL Yoga 5.24.4graphql-js 16.14.2DataLoader 2.2.3@escape.tech/graphql-armor-max-depth 2.4.2node:test

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

Содержание
  1. 01Схема как контракт: SDL
  2. 02Query, Mutation и переменные
  3. 03Резолверы
  4. 04Ошибки в GraphQL
  5. 05N+1 и DataLoader
  6. 06Безопасность: глубина и сложность запросов
  7. 07Клиент и тесты

Схема как контракт: SDL

В сравнении REST и GraphQL мы собрали карточку услуги одним запросом. Теперь построим этот сервер с нуля: каталог услуг студии с категориями и отзывами. Данные лежат в памяти, но каждый метод «базы» печатает SQL, который выполнила бы настоящая СУБД, — так видно, сколько запросов стоит каждая операция. Сервер — GraphQL Yoga, тесты — встроенный в Node.js node:test.

bash
mkdir catalog-graphql && cd catalog-graphql
npm init -y
npm pkg set type=module scripts.start="node src/server.js" scripts.test="node --test"
npm pkg delete main keywords author license description
npm i graphql-yoga@5.24.4 graphql@16.14.2 dataloader@2.2.3 @escape.tech/graphql-armor-max-depth@2.4.2
Проект catalog-graphql: ES-модули, скрипты start и test, точные версии зависимостей
package.json
{
  "name": "catalog-graphql",
  "version": "1.0.0",
  "scripts": {
    "test": "node --test",
    "start": "node src/server.js"
  },
  "type": "module",
  "dependencies": {
    "@escape.tech/graphql-armor-max-depth": "^2.4.2",
    "dataloader": "^2.2.3",
    "graphql": "^16.14.2",
    "graphql-yoga": "^5.24.4"
  }
}
Реальный package.json после команд выше

Схема — контракт между сервером и клиентами: какие есть типы, поля и операции. Её пишут на SDL (schema definition language) — языке, похожем на сами запросы. Объектный тип (type) перечисляет поля и их типы; Query — точка входа для чтения, Mutation — для изменений. Строки в кавычках перед полем — описания: они попадают в документацию и подсказки редактора.

src/typeDefs.js
export const typeDefs = /* GraphQL */ `
  "Услуга из каталога"
  type Service {
    id: ID!
    title: String!
    category: Category!
    reviews: [Review!]!
  }

  type Category {
    id: ID!
    name: String!
    services: [Service!]!
  }

  type Review {
    id: ID!
    "Оценка от 1 до 5"
    rating: Int!
    text: String!
  }

  type Query {
    "Список услуг; first — сколько вернуть (1–50)"
    services(categoryId: ID, first: Int = 10): [Service!]!
    "Одна услуга или null, если такой нет"
    service(id: ID!): Service
    categories: [Category!]!
  }

  input AddReviewInput {
    serviceId: ID!
    rating: Int!
    text: String!
  }

  type Mutation {
    addReview(input: AddReviewInput!): Review!
  }
`;
Схема каталога. Комментарий /* GraphQL */ включает подсветку SDL в редакторе

Int, Float, String, Boolean, ID

Что значит
Встроенные скаляры. ID сериализуется как строка, но означает идентификатор, а не текст для человека
Пример из схемы
id: ID!

T!

Что значит
Non-null: значение обязательно
Пример из схемы
title: String!

T без !

Что значит
Может быть null
Пример из схемы
service(id: ID!): Service — null, если услуги нет

[T!]!

Что значит
Список не null и без null внутри
Пример из схемы
reviews: [Review!]! — пустой список, а не null

arg: T = значение

Что значит
Аргумент со значением по умолчанию
Пример из схемы
first: Int = 10

input

Что значит
Тип для входных данных: поля без аргументов, нельзя смешивать с выходными типами
Пример из схемы
AddReviewInput

Обратите внимание на цикл: у категории есть услуги, у услуги — категория. Это удобно клиенту, но позволяет строить сколь угодно глубокие запросы; к этому вернёмся в разделе о безопасности.

Query, Mutation и переменные

Сервер собирают из схемы и резолверов. createSchema из Yoga связывает SDL с функциями, createYoga превращает схему в обработчик HTTP на адресе /graphql, а функция context создаёт для каждого запроса общий объект, который получат все резолверы: доступ к базе и набор загрузчиков. Сами резолверы и загрузчики разберём в следующих разделах.

src/yoga.js
import { createSchema, createYoga } from "graphql-yoga";
import { maxDepthPlugin } from "@escape.tech/graphql-armor-max-depth";
import { typeDefs } from "./typeDefs.js";
import { resolvers as defaultResolvers } from "./resolvers.js";
import { createLoaders } from "./loaders.js";

export function buildYoga({ db, resolvers = defaultResolvers, maxDepth = 5 }) {
  return createYoga({
    schema: createSchema({ typeDefs, resolvers }),
    // контекст создаётся заново на каждый запрос — вместе с загрузчиками
    context: () => ({ db, loaders: createLoaders(db) }),
    plugins: [maxDepthPlugin({ n: maxDepth })],
  });
}
Сборка сервера: схема, контекст на каждый запрос, лимит глубины 5
src/server.js
import { createServer } from "node:http";
import { createDb } from "./db.js";
import { buildYoga } from "./yoga.js";

const port = Number(process.env.PORT ?? 4400);
const db = createDb({ onQuery: (sql, params) => console.log("[db]", sql, JSON.stringify(params)) });
const yoga = buildYoga({ db });
const server = createServer(yoga);

server.listen(port, () => {
  console.log(`GraphQL: http://localhost:${port}${yoga.graphqlEndpoint}`);
});
Точка входа: каждый запрос к «базе» печатается в консоль сервера, порт берётся из PORT

Запустите npm start и отправьте первый запрос. Сокращённая запись { … } — анонимная операция query:

terminal
$ curl -s http://localhost:4400/graphql -H "content-type: application/json" -d '{"query":"{ services(first: 3) { title category { name } } }"}'
{"data":{"services":[{"title":"Корпоративный сайт","category":{"name":"Веб-разработка"}},{"title":"Интернет-магазин","category":{"name":"Веб-разработка"}},{"title":"Приложение на Flutter","category":{"name":"Мобильные приложения"}}]}}
Реальный вывод: ответ повторяет форму запроса
terminal
$ npm start

> catalog-graphql@1.0.0 start
> node src/server.js

GraphQL: http://localhost:4400/graphql
[db] SELECT * FROM services WHERE ($1::text IS NULL OR category_id = $1) ORDER BY id LIMIT $2 [null,3]
[db] SELECT * FROM categories WHERE id = ANY($1) [["c1","c2"]]
Консоль сервера: два запроса к базе на три услуги — категории загружены одним пакетом, ключ c1 не повторяется

Значения из интерфейса — id категории, текст отзыва — нельзя вклеивать в строку запроса: graphql.org прямо запрещает строить запросы интерполяцией пользовательских данных. Для этого есть переменные: в операции объявляют $имя с типом, а значения передают отдельным объектом variables. Сервер проверит типы до выполнения. Операции стоит давать имя (ByCategory, AddReview) — по нему проще искать запрос в логах.

requests/by-category.json
{
  "query": "query ByCategory($cat: ID!, $n: Int) { services(categoryId: $cat, first: $n) { id title reviews { rating text } } }",
  "variables": { "cat": "c3", "n": 5 }
}
Запрос с переменными: $cat обязателен (ID!), $n — нет
requests/add-review.json
{
  "query": "mutation AddReview($input: AddReviewInput!) { addReview(input: $input) { id rating text } }",
  "variables": { "input": { "serviceId": "s5", "rating": 5, "text": "Остатки в 1С и на сайте совпадают" } }
}
Мутация: значения формы приходят одним объектом input-типа
terminal
$ curl -s http://localhost:4400/graphql -H "content-type: application/json" -d @requests/by-category.json
{"data":{"services":[{"id":"s4","title":"Telegram-бот","reviews":[{"rating":4,"text":"Бот принимает заявки"}]},{"id":"s5","title":"Обмен с 1С","reviews":[]}]}}
$ curl -s http://localhost:4400/graphql -H "content-type: application/json" -d @requests/add-review.json
{"data":{"addReview":{"id":"r3","rating":5,"text":"Остатки в 1С и на сайте совпадают"}}}
$ curl -s http://localhost:4400/graphql -H "content-type: application/json" -d @requests/bad-rating.json
{"errors":[{"message":"rating должен быть от 1 до 5","locations":[{"line":1,"column":47}],"path":["addReview"],"extensions":{"code":"BAD_USER_INPUT"}}],"data":null}
Реальный вывод: выборка по категории, добавление отзыва, отклонённая оценка 10. В Git Bash на Windows тело с кириллицей надёжнее передавать файлом через -d @
terminal
[db] SELECT * FROM services WHERE ($1::text IS NULL OR category_id = $1) ORDER BY id LIMIT $2 ["c3",5]
[db] SELECT * FROM reviews WHERE service_id = ANY($1) ORDER BY id [["s4","s5"]]
[db] SELECT * FROM services WHERE id = $1 ["s5"]
[db] INSERT INTO reviews (service_id, rating, text) VALUES ($1, $2, $3) RETURNING * ["s5",5,"Остатки в 1С и на сайте совпадают"]
Консоль сервера: мутация проверила, что услуга есть, и сделала один INSERT; при оценке 10 до базы дело не дошло

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

Yoga принимает и GET — его удобно кэшировать, — но только для чтения. Мутация через GET получает 405 с заголовком Allow: POST, как требует спецификация GraphQL over HTTP:

terminal
$ curl -s "http://localhost:4400/graphql?query=%7Bcategories%7Bname%7D%7D"
{"data":{"categories":[{"name":"Веб-разработка"},{"name":"Мобильные приложения"},{"name":"Интеграции"}]}}
$ curl -si "http://localhost:4400/graphql?query=mutation%7BaddReview(input:%7BserviceId:%22s1%22,rating:5,text:%22x%22%7D)%7Bid%7D%7D"
HTTP/1.1 405 Method Not Allowed
Content-Type: application/json; charset=utf-8
Allow: POST

{"errors":[{"message":"Can only perform a mutation operation from a POST request.","extensions":{"code":"BAD_REQUEST"}}]}
Реальный вывод, служебные заголовки убраны

Резолверы

Резолвер — функция, которая возвращает значение одного поля. graphql-js вызывает её с четырьмя аргументами: родительский объект (для полей Query и Mutation не используется), аргументы поля, контекст запроса и info — сведения о текущем поле и схеме для продвинутых случаев. Резолвер может вернуть значение, промис или выбросить ошибку.

src/resolvers.js
import { GraphQLError } from "graphql";

function badInput(message) {
  return new GraphQLError(message, { extensions: { code: "BAD_USER_INPUT" } });
}

export const resolvers = {
  Query: {
    services: (_parent, { categoryId, first }, { db }) => {
      if (first < 1 || first > 50) throw badInput("first должен быть от 1 до 50");
      return db.listServices({ categoryId, limit: first });
    },
    service: (_parent, { id }, { db }) => db.findService(id),
    categories: (_parent, _args, { db }) => db.listCategories(),
  },

  Mutation: {
    addReview: async (_parent, { input }, { db }) => {
      const text = input.text.trim();
      if (input.rating < 1 || input.rating > 5) throw badInput("rating должен быть от 1 до 5");
      if (text.length === 0 || text.length > 1000) throw badInput("text: от 1 до 1000 символов");
      const service = await db.findService(input.serviceId);
      if (!service) {
        throw new GraphQLError(`Услуга ${input.serviceId} не найдена`, {
          extensions: { code: "NOT_FOUND" },
        });
      }
      return db.insertReview({ serviceId: service.id, rating: input.rating, text });
    },
  },

  // поля-связи: родительский объект приходит первым аргументом
  Service: {
    category: (service, _args, { loaders }) => loaders.categoryById.load(service.categoryId),
    reviews: (service, _args, { loaders }) => loaders.reviewsByServiceId.load(service.id),
  },

  Category: {
    services: (category, _args, { loaders }) => loaders.servicesByCategoryId.load(category.id),
  },
};
Резолверы каталога: чтение, мутация с проверкой ввода и поля-связи через загрузчики
  1. Корневые поля

    Query.services получает аргументы categoryId и first (если клиент не передал first, сработает значение по умолчанию 10) и идёт в базу через context.db.

  2. Поля по умолчанию

    Для Service.title или Review.rating резолвера нет: Yoga, как и большинство библиотек, читает одноимённое свойство объекта. Писать title: (s) => s.title не нужно.

  3. Поля-связи

    Service.category получает услугу первым аргументом и по её categoryId загружает категорию. Так GraphQL проходит по графу: каждый уровень вложенности — новый вызов резолверов для каждого объекта.

  4. Мутация

    addReview сначала проверяет бизнес-правила (оценка от 1 до 5, длина текста, существование услуги), а потом пишет. Типы GraphQL гарантируют, что rating — целое число, но диапазон проверяет только ваш код.

Ошибки в GraphQL

Ответ GraphQL — JSON с ключами data и errors. Ошибки запроса — синтаксис, неизвестное поле, переменная не того типа — находятся до выполнения: ключа data нет. Ошибки выполнения выбрасывают резолверы: остальные поля выполняются, а в errors приходят сообщение, path до поля и extensions.

  • Ожидаемую ошибку выбрасывайте как GraphQLError с кодом в extensions.code — BAD_USER_INPUT, NOT_FOUND. Клиент ветвится по коду, а не по тексту сообщения.
  • «Не найдено» для одиночного объекта часто выражают через null, а не ошибку: service(id: "s404") вернёт { "service": null }. Для мутации, где отсутствие объекта — ошибка, — NOT_FOUND.
  • Если ошибка случилась в non-null поле, null поднимается к ближайшему родителю, который может быть null. В выводе выше addReview: Review! упал, и весь data стал null.
  • Любую другую ошибку — упавшую базу, опечатку в коде — Yoga маскирует: клиент видит Unexpected error. с кодом INTERNAL_SERVER_ERROR, а подробности пишутся в лог сервера. Это видно в выводе тестов ниже.
terminal
$ curl -s -w "\nHTTP %{http_code}\n" http://localhost:4400/graphql -H "content-type: application/json" -H "accept: application/json" -d @requests/typo.json
{"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 @requests/typo.json
{"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
Реальный вывод: опечатка в поле — ошибка валидации. Статус зависит от заголовка Accept: 200 для старого application/json, 400 для application/graphql-response+json

Клиенту важно правило: проверять ключ errors в теле, а не только HTTP-статус. Ответ с ошибкой выполнения приходит со статусом 200 — подробно о статусах и черновике спецификации GraphQL over HTTP рассказано в сравнении REST и GraphQL.

N+1 и DataLoader

Резолвер поля вызывается для каждого объекта отдельно. Если Service.category ходит в базу напрямую, список из пяти услуг даст один запрос за списком и пять — за категориями. Добавьте отзывы — ещё пять. Это проблема N+1: число запросов растёт вместе с длиной списка. «База» учебного проекта считает запросы — вот она целиком:

src/db.js
// «База данных» в памяти. Каждый метод — один запрос: onQuery получает
// SQL, который выполнила бы настоящая СУБД, — по этим вызовам считаем запросы.
export function createDb({ onQuery = () => {} } = {}) {
  const categories = [
    { id: "c1", name: "Веб-разработка" },
    { id: "c2", name: "Мобильные приложения" },
    { id: "c3", name: "Интеграции" },
  ];
  const services = [
    { id: "s1", title: "Корпоративный сайт", categoryId: "c1" },
    { id: "s2", title: "Интернет-магазин", categoryId: "c1" },
    { id: "s3", title: "Приложение на Flutter", categoryId: "c2" },
    { id: "s4", title: "Telegram-бот", categoryId: "c3" },
    { id: "s5", title: "Обмен с 1С", categoryId: "c3" },
  ];
  const reviews = [
    { id: "r1", serviceId: "s1", rating: 5, text: "Сайт сдали в срок" },
    { id: "r2", serviceId: "s4", rating: 4, text: "Бот принимает заявки" },
  ];

  async function query(sql, params, run) {
    onQuery(sql, params);
    return run();
  }

  return {
    listCategories: () =>
      query("SELECT * FROM categories ORDER BY id", [], () => [...categories]),

    listServices: ({ categoryId, limit }) =>
      query(
        "SELECT * FROM services WHERE ($1::text IS NULL OR category_id = $1) ORDER BY id LIMIT $2",
        [categoryId ?? null, limit],
        () => services.filter((s) => !categoryId || s.categoryId === categoryId).slice(0, limit),
      ),

    findService: (id) =>
      query("SELECT * FROM services WHERE id = $1", [id], () => services.find((s) => s.id === id) ?? null),

    findCategory: (id) =>
      query("SELECT * FROM categories WHERE id = $1", [id], () => categories.find((c) => c.id === id) ?? null),

    // пакетные запросы для DataLoader: один запрос на много ключей
    categoriesByIds: (ids) =>
      query("SELECT * FROM categories WHERE id = ANY($1)", [ids], () =>
        categories.filter((c) => ids.includes(c.id)),
      ),

    servicesByCategoryIds: (ids) =>
      query("SELECT * FROM services WHERE category_id = ANY($1) ORDER BY id", [ids], () =>
        services.filter((s) => ids.includes(s.categoryId)),
      ),

    reviewsByServiceIds: (ids) =>
      query("SELECT * FROM reviews WHERE service_id = ANY($1) ORDER BY id", [ids], () =>
        reviews.filter((r) => ids.includes(r.serviceId)),
      ),

    insertReview: ({ serviceId, rating, text }) =>
      query("INSERT INTO reviews (service_id, rating, text) VALUES ($1, $2, $3) RETURNING *", [serviceId, rating, text], () => {
        const review = { id: `r${reviews.length + 1}`, serviceId, rating, text };
        reviews.push(review);
        return review;
      }),
  };
}
«База» в памяти: обычные запросы и пакетные = ANY($1), каждый вызов сообщается в onQuery
test/api.test.js
const naive = {
  ...resolvers,
  Service: {
    category: (service, _args, { db }) => db.findCategory(service.categoryId),
    reviews: async (service, _args, { db }) => db.reviewsByServiceIds([service.id]),
  },
};
Фрагмент теста: наивные резолверы, которые ходят в базу на каждый объект. Тот же запрос списка — 11 запросов к базе

DataLoader решает это пакетной загрузкой. Резолверы вызывают loader.load(id), а DataLoader собирает все вызовы, сделанные в одном такте выполнения, и один раз вызывает пакетную функцию со списком ключей. Внутри — один запрос WHERE id = ANY($1).

src/loaders.js
import DataLoader from "dataloader";

// Пакетная функция обязана вернуть массив той же длины и в том же порядке,
// что и keys: базе порядок не важен, поэтому раскладываем строки по ключам.
function byKey(keys, rows, key) {
  const map = new Map(rows.map((row) => [row[key], row]));
  return keys.map((k) => map.get(k) ?? new Error(`Нет записи ${k}`));
}

function groupByKey(keys, rows, key) {
  const groups = new Map(keys.map((k) => [k, []]));
  for (const row of rows) groups.get(row[key])?.push(row);
  return keys.map((k) => groups.get(k));
}

// Новый набор загрузчиков на каждый HTTP-запрос: кэш DataLoader живёт
// только в пределах запроса и не смешивает данные разных пользователей.
export function createLoaders(db) {
  return {
    categoryById: new DataLoader(async (ids) => byKey(ids, await db.categoriesByIds(ids), "id")),
    servicesByCategoryId: new DataLoader(async (ids) =>
      groupByKey(ids, await db.servicesByCategoryIds(ids), "categoryId"),
    ),
    reviewsByServiceId: new DataLoader(async (ids) =>
      groupByKey(ids, await db.reviewsByServiceIds(ids), "serviceId"),
    ),
  };
}
Три загрузчика: один объект по id и две связи один-ко-многим

Пакетная функция возвращает массив той же длины и в том же порядке, что keys

Почему
Иначе категория одной услуги достанется другой. База порядок не гарантирует — раскладываем строки по ключам через Map

Для отсутствующего ключа — экземпляр Error, а не исключение

Почему
Ошибка достанется только тому load, который просил этот ключ; остальные получат данные

Новые загрузчики на каждый запрос

Почему
У DataLoader есть кэш: повторный load того же ключа не идёт в базу. Общий кэш на всех вернул бы устаревшие данные или чужие объекты

DataLoader — не замена Redis

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

Результат проверен тестами: тот же запрос «пять услуг с категориями и отзывами» с загрузчиками делает 3 запроса вместо 11 — список, один пакет категорий и один пакет отзывов. Число запросов больше не зависит от длины списка. Если пакетный запрос всё равно медленный, смотрите план запроса и индексы — это тема урока про EXPLAIN.

Безопасность: глубина и сложность запросов

Запрос составляет клиент, поэтому сервер должен отклонять слишком дорогие запросы до выполнения. Цикл «категория → услуги → категория» позволяет вложить поля сколько угодно раз. Документация Yoga для публичных API советует плагины GraphQL Armor; мы подключили лимит глубины maxDepthPlugin({ n: 5 }) — он работает на этапе валидации, до резолверов.

terminal
$ curl -s -w "\nHTTP %{http_code}\n" http://localhost:4400/graphql -H "content-type: application/json" -d @requests/deep.json
{"errors":[{"message":"Syntax Error: Query depth limit of 5 exceeded, found 6."}]}
HTTP 200
Запрос глубины 6 (requests/deep.json) отклонён; в консоли сервера нет ни одного запроса к базе

Обратите внимание: плагин отвечает статусом 200 и без data, а текст начинается с Syntax Error — так его формулирует версия 2.4.2. Клиенту это не мешает, если он проверяет errors. Ограничение глубины — только одна мера. Остальные из руководства graphql.org по безопасности:

Лимит размера списков

Как сделать в Yoga
Аргумент first с проверкой, как в Query.services; для больших списков — курсорная пагинация

Лимит алиасов и числа токенов

Как сделать в Yoga
Плагины @escape.tech/graphql-armor-max-aliases и -max-tokens

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

Как сделать в Yoga
Плагин @escape.tech/graphql-armor-cost-limit: у каждого поля вес, запрос дороже порога отклоняется

Только известные операции

Как сделать в Yoga
Persisted operations — для API только своих клиентов: сервер выполняет операции по хэшу из списка

Права на уровне полей

Как сделать в Yoga
Проверка пользователя из контекста в резолверах или бизнес-логике

Клиент и тесты

Для запроса из фронтенда не обязательна библиотека: GraphQL — это POST с JSON. Минимальный клиент на fetch отправляет операцию и переменные, сообщает, что понимает application/graphql-response+json, и превращает errors в исключение. В браузере код тот же, если сервер разрешает CORS для вашего сайта; клиентские GraphQL-библиотеки добавляют к этому кэш и интеграцию с фреймворками.

src/client.js
// Минимальный клиент: тот же код работает в Node.js 18+ и в браузере
// (как <script type="module">, если сервер разрешает CORS для вашего сайта).
const ENDPOINT = "http://localhost:4400/graphql";

export async function gql(query, variables = {}) {
  const res = await fetch(ENDPOINT, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      accept: "application/graphql-response+json, application/json",
    },
    body: JSON.stringify({ query, variables }),
  });
  const body = await res.json();
  // ошибки GraphQL приходят в теле — проверяем errors, а не только статус
  if (body.errors?.length) {
    const [first] = body.errors;
    throw new Error(`${first.extensions?.code ?? "GRAPHQL_ERROR"}: ${first.message}`);
  }
  return body.data;
}

const SERVICES = /* GraphQL */ `
  query ServicesByCategory($categoryId: ID) {
    services(categoryId: $categoryId) {
      id
      title
      reviews { rating }
    }
  }
`;

const data = await gql(SERVICES, { categoryId: "c3" });
for (const s of data.services) {
  console.log(`${s.id} ${s.title} — отзывов: ${s.reviews.length}`);
}

try {
  await gql(`query Many($n: Int) { services(first: $n) { id } }`, { n: 100 });
} catch (err) {
  console.log("Ошибка:", err.message);
}
Клиент на fetch: запрос с переменной и разбор ошибки по extensions.code
terminal
$ node src/client.js
s4 Telegram-бот — отзывов: 1
s5 Обмен с 1С — отзывов: 1
Ошибка: BAD_USER_INPUT: first должен быть от 1 до 50
Реальный вывод при запущенном сервере (после мутации выше у s5 появился отзыв)

Тесты не поднимают сеть: yoga.fetch обрабатывает запрос в памяти, как описано в документации Yoga. Счётчик запросов к «базе» превращает N+1 в проверяемое утверждение, а тест с подменённым методом базы проверяет, что детали ошибки не уходят клиенту.

test/api.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { createDb } from "../src/db.js";
import { createLoaders } from "../src/loaders.js";
import { resolvers } from "../src/resolvers.js";
import { buildYoga } from "../src/yoga.js";

// Тестовый стенд: своя «БД» со счётчиком запросов и сервер без сети —
// yoga.fetch обрабатывает запрос в памяти, порт не открывается.
function setup(options = {}) {
  const queries = [];
  const db = createDb({ onQuery: (sql) => queries.push(sql) });
  const yoga = buildYoga({ db, ...options });
  async function gql(query, variables) {
    queries.length = 0;
    const res = await yoga.fetch("http://yoga/graphql", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ query, variables }),
    });
    return { status: res.status, body: await res.json() };
  }
  return { db, gql, queries };
}

const LIST = /* GraphQL */ `
  query ServiceList {
    services {
      title
      category { name }
      reviews { rating }
    }
  }
`;

test("запрос с переменной возвращает только запрошенные поля", async () => {
  const { gql } = setup();
  const { status, body } = await gql(
    `query ByCategory($cat: ID!) { services(categoryId: $cat) { id title } }`,
    { cat: "c3" },
  );
  assert.equal(status, 200);
  assert.deepEqual(body, {
    data: {
      services: [
        { id: "s4", title: "Telegram-бот" },
        { id: "s5", title: "Обмен с 1С" },
      ],
    },
  });
});

test("DataLoader: 5 услуг с категориями и отзывами — 3 запроса к БД", async () => {
  const { gql, queries } = setup();
  const { body } = await gql(LIST);
  assert.equal(body.data.services.length, 5);
  assert.equal(body.data.services[3].category.name, "Интеграции");
  assert.deepEqual(queries, [
    "SELECT * FROM services WHERE ($1::text IS NULL OR category_id = $1) ORDER BY id LIMIT $2",
    "SELECT * FROM categories WHERE id = ANY($1)",
    "SELECT * FROM reviews WHERE service_id = ANY($1) ORDER BY id",
  ]);
});

test("без DataLoader тот же запрос даёт N+1: 1 + 5 + 5 = 11 запросов", async () => {
  const naive = {
    ...resolvers,
    Service: {
      category: (service, _args, { db }) => db.findCategory(service.categoryId),
      reviews: async (service, _args, { db }) => db.reviewsByServiceIds([service.id]),
    },
  };
  const { gql, queries } = setup({ resolvers: naive });
  const { body } = await gql(LIST);
  assert.equal(body.data.services.length, 5);
  assert.equal(queries.length, 11);
  assert.equal(queries.filter((q) => q.startsWith("SELECT * FROM categories WHERE id = $1")).length, 5);
});

test("пакетная функция сохраняет порядок ключей и возвращает ошибку для отсутствующего", async () => {
  const db = createDb();
  const { categoryById } = createLoaders(db);
  const result = await categoryById.loadMany(["c3", "c1", "zz"]);
  assert.equal(result[0].name, "Интеграции");
  assert.equal(result[1].name, "Веб-разработка");
  assert.ok(result[2] instanceof Error);
});

test("несуществующая услуга — null без ошибки", async () => {
  const { gql } = setup();
  const { body } = await gql(`{ service(id: "s404") { title } }`);
  assert.deepEqual(body, { data: { service: null } });
});

test("ошибка в non-null поле обнуляет родителя, в errors есть path и code", async () => {
  const { gql, queries } = setup();
  const { status, body } = await gql(`{ services(first: 100) { title } }`);
  assert.equal(status, 200);
  assert.equal(body.data, null);
  assert.deepEqual(body.errors[0].path, ["services"]);
  assert.equal(body.errors[0].extensions.code, "BAD_USER_INPUT");
  assert.equal(queries.length, 0);
});

test("мутация добавляет отзыв, следующий запрос его видит", async () => {
  const { gql } = setup();
  const READ = `{ service(id: "s2") { reviews { id } } }`;
  const before = await gql(READ);
  assert.deepEqual(before.body.data.service.reviews, []);
  const add = await gql(
    `mutation AddReview($input: AddReviewInput!) { addReview(input: $input) { id rating text } }`,
    { input: { serviceId: "s2", rating: 5, text: "  Каталог удобный  " } },
  );
  assert.deepEqual(add.body.data.addReview, { id: "r3", rating: 5, text: "Каталог удобный" });
  const read = await gql(READ); // новый запрос — новые загрузчики, без устаревшего кэша
  assert.deepEqual(read.body.data.service.reviews, [{ id: "r3" }]);
});

test("мутация проверяет ввод и отклоняет неизвестную услугу без записи", async () => {
  const { gql, queries } = setup();
  const MUTATION = `mutation AddReview($input: AddReviewInput!) { addReview(input: $input) { id } }`;
  const bad = await gql(MUTATION, { input: { serviceId: "s1", rating: 9, text: "ok" } });
  assert.equal(bad.body.errors[0].extensions.code, "BAD_USER_INPUT");
  const missing = await gql(MUTATION, { input: { serviceId: "s404", rating: 5, text: "ok" } });
  assert.equal(missing.body.errors[0].extensions.code, "NOT_FOUND");
  assert.ok(!queries.some((q) => q.startsWith("INSERT")));
});

test("переменная не того типа — 400 до выполнения", async () => {
  const { gql, queries } = setup();
  const { status, body } = await gql(
    `mutation AddReview($input: AddReviewInput!) { addReview(input: $input) { id } }`,
    { input: { serviceId: "s1", rating: "5", text: "ok" } },
  );
  assert.equal(status, 400);
  assert.match(body.errors[0].message, /Int cannot represent non-integer value/);
  assert.equal(queries.length, 0);
});

test("слишком глубокий запрос отклоняется до обращения к БД", async () => {
  const { gql, queries } = setup();
  const { body } = await gql(
    `{ categories { services { category { services { category { name } } } } } }`,
  );
  assert.equal(body.data, undefined);
  assert.match(body.errors[0].message, /depth limit of 5 exceeded, found 6/);
  assert.equal(queries.length, 0);
});

test("неожиданная ошибка маскируется: детали не уходят клиенту", async () => {
  const db = createDb();
  db.listCategories = async () => {
    throw new Error("connect ECONNREFUSED 10.0.0.5:5432");
  };
  const yoga = buildYoga({ db });
  const res = await yoga.fetch("http://yoga/graphql", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ query: "{ categories { name } }" }),
  });
  const body = await res.json();
  assert.equal(body.errors[0].message, "Unexpected error.");
  assert.doesNotMatch(JSON.stringify(body), /ECONNREFUSED/);
});
11 тестов: переменные, DataLoader против N+1, порядок ключей, null и распространение ошибки, мутация, 400 за тип переменной, лимит глубины, маскирование
terminal
$ npm test

> catalog-graphql@1.0.0 test
> node --test

✔ запрос с переменной возвращает только запрошенные поля (27.7426ms)
ERR Error: connect ECONNREFUSED 10.0.0.5:5432
    at db.listCategories (file:///…/catalog-graphql/test/api.test.js:153:11)
    …
  path: [ 'categories' ],
  locations: [ { line: 1, column: 3 } ],
  extensions: [Object: null prototype] {}
}
✔ DataLoader: 5 услуг с категориями и отзывами — 3 запроса к БД (3.5657ms)
✔ без DataLoader тот же запрос даёт N+1: 1 + 5 + 5 = 11 запросов (3.0164ms)
✔ пакетная функция сохраняет порядок ключей и возвращает ошибку для отсутствующего (0.2793ms)
✔ несуществующая услуга — null без ошибки (2.3158ms)
✔ ошибка в non-null поле обнуляет родителя, в errors есть path и code (2.8934ms)
✔ мутация добавляет отзыв, следующий запрос его видит (3.8273ms)
✔ мутация проверяет ввод и отклоняет неизвестную услугу без записи (2.982ms)
✔ переменная не того типа — 400 до выполнения (2.0594ms)
✔ слишком глубокий запрос отклоняется до обращения к БД (2.4009ms)
✔ неожиданная ошибка маскируется: детали не уходят клиенту (2.1038ms)
ℹ tests 11
ℹ suites 0
ℹ pass 11
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 323.6923
Реальный вывод на Node.js 24.18. Строка ERR — лог Yoga о замаскированной ошибке из последнего теста; стек и пути сокращены

Тесты мы проверили обратной заменой: если создавать загрузчики один раз на весь сервер, падает тест «следующий запрос видит отзыв» — кэш возвращает устаревший пустой список; если пакетная функция возвращает строки в порядке базы, падает тест порядка ключей; без плагина глубины падает тест лимита.

Загрузчики созданы один раз при старте

Симптом
Устаревшие данные, чужие объекты в ответе
Исправление
Создавать в context на каждый запрос

Пакетная функция вернула строки как есть

Симптом
Категории перепутаны, ошибка про длину массива
Исправление
Раскладывать по ключам через Map

Значения вклеены в текст запроса

Симптом
Ошибки синтаксиса на кавычках, инъекции
Исправление
Переменные $name и объект variables

Клиент смотрит только на статус

Симптом
Ошибки выполнения «не видны» при 200
Исправление
Проверять errors в теле

Ошибки — обычный Error

Симптом
Клиент получает Unexpected error.
Исправление
GraphQLError с extensions.code для ожидаемых ошибок

Два экземпляра graphql в node_modules

Симптом
500 и Unexpected error. из плагинов
Исправление
npm ls graphql, одна версия на проект

Это учебный сервер: перед продакшеном ему нужны аутентификация и права на уровне полей, настоящая база с пулом соединений, пагинация всех списков, лимиты стоимости и частоты, журнал операций и мониторинг ответов с errors.

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

    setup.sh + package.json (catalog-graphql: graphql-yoga 5.24.4, graphql 16.14.2, dataloader 2.2.3, @escape.tech/graphql-armor-max-depth 2.4.2)

    команды выполнены в пустой папке на Node.js 24.18 / npm 11.16; package.json — реальный результат; npm ls — одна копия graphql

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

    src/typeDefs.js, src/resolvers.js, src/loaders.js, src/db.js, src/yoga.js, src/server.js

    сервер запущен (порт 4400), запросы curl: query, переменные, мутация, BAD_USER_INPUT, GET и 405 на GET-мутацию, валидация 200/400 по Accept, лимит глубины; вывод и журнал [db] скопированы из реального запуска

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

    test/api.test.js (node:test + yoga.fetch, 11 тестов, включая N+1 11 запросов против 3 с DataLoader)

    npm test: 11/11 pass в рабочем и в свежем проекте; мутационная проверка: глобальные загрузчики, порядок строк из БД, отключённый плагин глубины — каждый ломает ровно один тест

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

    решение упражнения reviewCount (diff)

    diff применён к свежему проекту (git apply --check), запрос services{title reviewCount reviews{rating}} — 2 запроса к БД, тесты 11/11; алиасы a/b — 2 запроса, вывод в решении реальный

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

    несовместимость graphql 17.0.2 и graphql-armor-max-depth 2.4.2

    отдельный проект с graphql@17.0.2: npm ls показывает вложенный graphql 16.14.2 у плагина, глубокий запрос даёт HTTP 500 Unexpected error.; NODE_ENV=development → extensions.originalError со стеком — проверено yoga.fetch

  • частично

    src/client.js (fetch-клиент)

    запущен в Node.js 24.18 против сервера, вывод скопирован; в браузере не запускался (утверждение о браузере ограничено условием CORS)

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

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

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

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

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

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

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