How to buildOctober 11, 202611 min read
APIs & IntegrationsStep 2 of 10

GraphQL: schema, queries and mutations

In short

A GraphQL server consists of a schema and resolvers. The SDL schema describes the types, the Query and Mutation entry points and their arguments, while resolvers are functions that return field values from a database or other services. The client sends POST /graphql with the operation text and variables and gets back JSON of the same shape. To survive arbitrary queries, the server loads relationships in batches with DataLoader and limits query depth and cost before execution.

TutorialLevel: IntermediateGraphQLPractice: ~1.5–3 h

After this you will be able to

  • describe an SDL schema with types, Query, Mutation and an input type, and run a GraphQL Yoga server on it
  • write resolvers with arguments and context, and run a query and a mutation with variables via curl and fetch
  • spot N+1 with a database query counter and fix it with DataLoader
  • return an expected error with extensions.code, hide unexpected ones and limit query depth

Verified with

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

Checked against docs: October 11, 2026

Contents
  1. 01The schema as a contract: SDL
  2. 02Query, Mutation and variables
  3. 03Resolvers
  4. 04Errors in GraphQL
  5. 05N+1 and DataLoader
  6. 06Security: query depth and complexity
  7. 07The client and tests

The schema as a contract: SDL

In the REST vs GraphQL comparison we built a service card with a single request. Now let's build that server from scratch: a studio service catalogue with categories and reviews. The data lives in memory, but every "database" method prints the SQL a real DBMS would run, so you can see how many queries each operation costs. The server is GraphQL Yoga, and the tests use Node.js's built-in 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
The catalog-graphql project: ES modules, start and test scripts, exact dependency versions
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"
  }
}
The real package.json after the commands above

The schema is the contract between the server and its clients: which types, fields and operations exist. It is written in SDL (schema definition language), which looks like the queries themselves. An object type (type) lists fields and their types; Query is the entry point for reads and Mutation for changes. Quoted strings before a field are descriptions: they show up in documentation and editor hints.

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!
  }
`;
The catalogue schema. The /* GraphQL */ comment enables SDL highlighting in the editor

Int, Float, String, Boolean, ID

Meaning
Built-in scalars. ID is serialised as a string but means an identifier, not human-readable text
Example from the schema
id: ID!

T!

Meaning
Non-null: a value is required
Example from the schema
title: String!

T without !

Meaning
May be null
Example from the schema
service(id: ID!): Service — null if there is no such service

[T!]!

Meaning
The list is not null and holds no nulls
Example from the schema
reviews: [Review!]! — an empty list, not null

arg: T = value

Meaning
An argument with a default value
Example from the schema
first: Int = 10

input

Meaning
A type for input data: fields without arguments, cannot be mixed with output types
Example from the schema
AddReviewInput

Note the cycle: a category has services, and a service has a category. That is convenient for clients but allows arbitrarily deep queries; we come back to this in the security section.

Query, Mutation and variables

The server is assembled from the schema and the resolvers. Yoga's createSchema binds SDL to functions, createYoga turns the schema into an HTTP handler at /graphql, and the context function creates, for every request, a shared object that all resolvers receive: database access and a set of loaders. We look at the resolvers and loaders themselves in the next sections.

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 })],
  });
}
Server assembly: schema, per-request context, depth limit of 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}`);
});
Entry point: every "database" query is printed to the server console, the port comes from PORT

Run npm start and send the first request. The shorthand { … } is an anonymous query operation:

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":"Мобильные приложения"}}]}}
Real output: the response mirrors the shape of the query
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"]]
Server console: two database queries for three services — categories are loaded in one batch, and key c1 is not repeated

Values from the interface — a category id, review text — must not be pasted into the query string: graphql.org explicitly says never to build queries by interpolating user-supplied values. That is what variables are for: the operation declares $name with a type, and the values travel in a separate variables object. The server checks the types before execution. Give operations a name (ByCategory, AddReview) — it makes them easier to find in logs.

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 }
}
A query with variables: $cat is required (ID!), $n is not
requests/add-review.json
{
  "query": "mutation AddReview($input: AddReviewInput!) { addReview(input: $input) { id rating text } }",
  "variables": { "input": { "serviceId": "s5", "rating": 5, "text": "Остатки в 1С и на сайте совпадают" } }
}
A mutation: form values arrive as one input-type object
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}
Real output: filtering by category, adding a review, a rejected rating of 10. In Git Bash on Windows, a body with Cyrillic text is safer to pass as a file via -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С и на сайте совпадают"]
Server console: the mutation checked that the service exists and ran one INSERT; with a rating of 10 the database was never touched

Query fields may run in parallel, while top-level Mutation fields run strictly one after another: the specification requires this because mutations change data. Serial execution is not a transaction: if an operation has two mutations and the second fails, the first has already been applied. That is why a mutation is usually one meaningful action that returns the changed object, so the client can update the screen right away.

Yoga also accepts GET — handy for caching — but only for reads. A mutation over GET gets 405 with an Allow: POST header, as the GraphQL over HTTP specification requires:

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"}}]}
Real output, housekeeping headers removed

Resolvers

A resolver is a function that returns the value of one field. graphql-js calls it with four arguments: the parent object (unused for Query and Mutation fields), the field's arguments, the request context, and info — details about the current field and schema for advanced cases. A resolver can return a value or a promise, or throw an error.

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),
  },
};
Catalogue resolvers: reads, a mutation with input checks, and relationship fields via loaders
  1. Root fields

    Query.services receives the categoryId and first arguments (if the client omits first, the default of 10 applies) and goes to the database through context.db.

  2. Default fields

    Service.title and Review.rating have no resolver: Yoga, like most libraries, reads the property of the same name. There is no need to write title: (s) => s.title.

  3. Relationship fields

    Service.category receives the service as its first argument and loads the category by its categoryId. That is how GraphQL walks the graph: each nesting level means another resolver call for every object.

  4. The mutation

    addReview first checks the business rules (a rating from 1 to 5, text length, the service exists) and only then writes. GraphQL types guarantee that rating is an integer, but only your code checks the range.

Errors in GraphQL

A GraphQL response is JSON with data and errors keys. Request errors — syntax, an unknown field, a variable of the wrong type — are found before execution, so there is no data key. Execution errors are thrown by resolvers: the other fields still execute, and errors carries the message, the path to the field and extensions.

  • Throw an expected error as a GraphQLError with a code in extensions.code — BAD_USER_INPUT, NOT_FOUND. The client branches on the code, not on the message text.
  • "Not found" for a single object is often expressed as null rather than an error: service(id: "s404") returns { "service": null }. For a mutation, where a missing object is an error, use NOT_FOUND.
  • If the error happens in a non-null field, the null propagates up to the nearest parent that may be null. In the output above addReview: Review! failed and the whole data became null.
  • Any other error — a database outage, a typo in code — is masked by Yoga: the client sees Unexpected error. with the INTERNAL_SERVER_ERROR code, and the details go to the server log. You can see this in the test output below.
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
Real output: a field typo is a validation error. The status depends on the Accept header: 200 for legacy application/json, 400 for application/graphql-response+json

The rule for clients: check the errors key in the body, not just the HTTP status. A response with an execution error arrives with status 200 — status codes and the GraphQL over HTTP draft are covered in detail in the REST vs GraphQL comparison.

N+1 and DataLoader

A field resolver is called separately for every object. If Service.category goes straight to the database, a list of five services means one query for the list and five for the categories. Add reviews and that is five more. This is the N+1 problem: the number of queries grows with the length of the list. The learning project's "database" counts queries — here it is in full:

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;
      }),
  };
}
The in-memory "database": regular queries and batched = ANY($1) ones, each call reported to 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]),
  },
};
Test fragment: naive resolvers that hit the database for every object. The same list query costs 11 database queries

DataLoader solves this with batching. Resolvers call loader.load(id), and DataLoader collects every call made within one tick of execution and invokes the batch function once with the list of keys. Inside it is a single WHERE id = ANY($1) query.

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"),
    ),
  };
}
Three loaders: one object by id and two one-to-many relationships

The batch function returns an array of the same length and in the same order as keys

Why
Otherwise one service gets another service's category. The database does not guarantee order, so rows are mapped back to keys with a Map

For a missing key, return an Error instance rather than throwing

Why
Only the load that asked for that key gets the error; the rest get their data

New loaders for every request

Why
DataLoader caches: a repeated load of the same key does not hit the database. A cache shared by everyone would return stale data or other users' objects

DataLoader is not a replacement for Redis

Why
Its cache lives within one request; an application-wide cache is a separate concern

The result is checked by tests: the same "five services with categories and reviews" query makes 3 queries with loaders instead of 11 — the list, one batch of categories and one batch of reviews. The query count no longer depends on the length of the list. If the batched query is still slow, look at the query plan and indexes — that is the topic of the lesson on EXPLAIN.

Security: query depth and complexity

The client composes the query, so the server must reject overly expensive queries before execution. The "category → services → category" cycle lets fields nest any number of times. For public APIs the Yoga documentation recommends the GraphQL Armor plugins; we added the depth limit maxDepthPlugin({ n: 5 }), which runs during validation, before any resolver.

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
A depth-6 query (requests/deep.json) is rejected; the server console shows no database queries

Note that the plugin answers with status 200 and no data, and the message starts with Syntax Error — that is how version 2.4.2 phrases it. This does not bother a client that checks errors. A depth limit is only one measure. The others from the graphql.org security guide:

Limit list sizes

How to do it in Yoga
A first argument with a check, as in Query.services; cursor pagination for large lists

Limit aliases and token count

How to do it in Yoga
The @escape.tech/graphql-armor-max-aliases and -max-tokens plugins

Cost analysis

How to do it in Yoga
The @escape.tech/graphql-armor-cost-limit plugin: each field has a weight, and a query above the threshold is rejected

Known operations only

How to do it in Yoga
Persisted operations — for APIs serving only your own clients: the server runs operations by hash from a list

Field-level permissions

How to do it in Yoga
Check the user from the context in resolvers or the business logic

The client and tests

A frontend request does not need a library: GraphQL is a POST with JSON. A minimal fetch client sends the operation and variables, says it understands application/graphql-response+json, and turns errors into an exception. The code is the same in the browser, provided the server allows CORS for your site; client GraphQL libraries add caching and framework integration on top.

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);
}
A fetch client: a query with a variable and error handling by extensions.code
terminal
$ node src/client.js
s4 Telegram-бот — отзывов: 1
s5 Обмен с 1С — отзывов: 1
Ошибка: BAD_USER_INPUT: first должен быть от 1 до 50
Real output with the server running (after the mutation above, s5 has a review)

The tests do not touch the network: yoga.fetch handles the request in memory, as the Yoga documentation describes. The "database" query counter turns N+1 into a testable assertion, and a test with a replaced database method checks that error details do not leak to the client.

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 tests: variables, DataLoader vs N+1, key order, null and error propagation, the mutation, 400 for a variable type, the depth limit, masking
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
Real output on Node.js 24.18. The ERR line is Yoga's log of the masked error from the last test; the stack and paths are shortened

We checked the tests by breaking the code on purpose: creating loaders once for the whole server fails the "next request sees the review" test because the cache returns a stale empty list; returning rows in database order from the batch function fails the key-order test; removing the depth plugin fails the limit test.

Loaders created once at startup

Symptom
Stale data, other users' objects in responses
Fix
Create them in context for every request

The batch function returned rows as is

Symptom
Mixed-up categories, an array length error
Fix
Map rows back to keys with a Map

Values pasted into the query text

Symptom
Syntax errors on quotes, injections
Fix
$name variables and a variables object

The client only checks the status

Symptom
Execution errors are "invisible" with 200
Fix
Check errors in the body

Errors thrown as plain Error

Symptom
The client gets Unexpected error.
Fix
GraphQLError with extensions.code for expected errors

Two copies of graphql in node_modules

Symptom
500 and Unexpected error. from plugins
Fix
npm ls graphql, one version per project

This is a learning server: before production it needs authentication and field-level permissions, a real database with a connection pool, pagination on every list, cost and rate limits, an operation log and monitoring of responses with errors.

How this was verified5 · passed1 · partialchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.