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.
After this you will be able to
- describe an SDL schema with types,
Query,Mutationand 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
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.
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{
"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 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.
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!
}
`;Int, Float, String, Boolean, ID
- Meaning
- Built-in scalars.
IDis 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—nullif there is no such service
[T!]!
- Meaning
- The list is not
nulland holds nonulls - Example from the schema
reviews: [Review!]!— an empty list, notnull
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.
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 })],
});
}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}`);
});Run npm start and send the first request. The shorthand { … } is an anonymous query operation:
$ 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":"Мобильные приложения"}}]}}$ 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"]]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.
{
"query": "query ByCategory($cat: ID!, $n: Int) { services(categoryId: $cat, first: $n) { id title reviews { rating text } } }",
"variables": { "cat": "c3", "n": 5 }
}$cat is required (ID!), $n is not{
"query": "mutation AddReview($input: AddReviewInput!) { addReview(input: $input) { id rating text } }",
"variables": { "input": { "serviceId": "s5", "rating": 5, "text": "Остатки в 1С и на сайте совпадают" } }
}$ 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}[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С и на сайте совпадают"]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:
$ 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"}}]}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.
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),
},
};Root fields
Query.servicesreceives thecategoryIdandfirstarguments (if the client omitsfirst, the default of 10 applies) and goes to the database throughcontext.db.Default fields
Service.titleandReview.ratinghave no resolver: Yoga, like most libraries, reads the property of the same name. There is no need to writetitle: (s) => s.title.Relationship fields
Service.categoryreceives the service as its first argument and loads the category by itscategoryId. That is how GraphQL walks the graph: each nesting level means another resolver call for every object.The mutation
addReviewfirst checks the business rules (a rating from 1 to 5, text length, the service exists) and only then writes. GraphQL types guarantee thatratingis 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
GraphQLErrorwith a code inextensions.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
nullrather than an error:service(id: "s404")returns{ "service": null }. For a mutation, where a missing object is an error, useNOT_FOUND. - If the error happens in a non-null field, the
nullpropagates up to the nearest parent that may benull. In the output aboveaddReview: Review!failed and the wholedatabecamenull. - Any other error — a database outage, a typo in code — is masked by Yoga: the client sees
Unexpected error.with theINTERNAL_SERVER_ERRORcode, and the details go to the server log. You can see this in the test output below.
$ 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 400The 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:
// «База данных» в памяти. Каждый метод — один запрос: 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) ones, each call reported to onQueryconst naive = {
...resolvers,
Service: {
category: (service, _args, { db }) => db.findCategory(service.categoryId),
reviews: async (service, _args, { db }) => db.reviewsByServiceIds([service.id]),
},
};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.
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"),
),
};
}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
loadthat asked for that key gets the error; the rest get their data
New loaders for every request
- Why
- DataLoader caches: a repeated
loadof 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.
$ 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 200Note 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
firstargument with a check, as inQuery.services; cursor pagination for large lists
Limit aliases and token count
- How to do it in Yoga
- The
@escape.tech/graphql-armor-max-aliasesand-max-tokensplugins
Cost analysis
- How to do it in Yoga
- The
@escape.tech/graphql-armor-cost-limitplugin: 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.
// Минимальный клиент: тот же код работает в 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);
}$ node src/client.js
s4 Telegram-бот — отзывов: 1
s5 Обмен с 1С — отзывов: 1
Ошибка: BAD_USER_INPUT: first должен быть от 1 до 50The 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.
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/);
});$ 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.6923We 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
contextfor 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
$namevariables and avariablesobject
The client only checks the status
- Symptom
- Execution errors are "invisible" with 200
- Fix
- Check
errorsin the body
Errors thrown as plain Error
- Symptom
- The client gets
Unexpected error. - Fix
GraphQLErrorwithextensions.codefor 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.