Представьте экран профиля в мобильном приложении: нужны имя пользователя, аватар и число заказов. В привычном REST вы дёргаете /users/42 — и получаете всё поле в поле: адрес, телефон, дату регистрации, настройки уведомлений. Половина ответа не нужна, но она приехала по проводу. А чтобы получить заказы, приходится делать ещё один запрос к /users/42/orders. GraphQL появился в Facebook именно из этой боли: пусть клиент сам опишет, какие данные ему нужны, и получит их одним запросом. Разберёмся, как это устроено и чем приходится платить.
Ниже — тот же экран профиля в REST и в GraphQL, и цена, которую сервер платит за гибкость.
Три круга по сети превращаются в один, и лишние поля не едут. Но тот же запрос на списке из 20 пользователей даёт 21 обращение к базе вместо двух — пока резолверы не батчат через dataloader.
Две проблемы REST: over-fetching и under-fetching
У жёстких REST-ответов есть два симметричных недостатка.
Over-fetching — пришло лишнее. Endpoint возвращает фиксированный набор полей, и если вам нужны только два из двадцати, остальные восемнадцать всё равно сериализуются, летят по сети и парсятся. На мобильной сети это заметно.
Under-fetching — пришло недостаточно, и приходится делать несколько запросов. Экран профиля с заказами — это /users/42, потом /users/42/orders, потом, может, /orders/{id}/items для каждого заказа. Так рождается «водопад» запросов: каждый следующий ждёт предыдущего, и экран собирается медленно.
REST борется с этим костылями — специальные endpoint'ы «под экран», параметры вроде ?fields=name,avatar. Работает, но контракт начинает обрастать частными случаями. GraphQL решает обе проблемы одним приёмом.
Идея: один endpoint и форма данных
В GraphQL, в отличие от REST, нет множества URL под каждый ресурс. Есть один endpoint (обычно /graphql), и клиент шлёт туда запрос, который описывает форму нужных данных — какие поля и какие вложенные объекты вернуть.
Вы не думаете «какой URL дёрнуть». Вы думаете «какой кусок графа данных мне нужен» и рисуете его прямо в запросе. Сервер возвращает JSON ровно той же формы — ничего лишнего, ничего недостающего.
Схема и резолверы
Клиент просит user { name orders { total } }, и сервер должен знать две вещи: что такое orders у user и откуда взять total для каждого заказа. Первое описывает схема: строго типизированное описание того, какие данные существуют и как они связаны; второе делают резолверы, о них ниже. Это контракт, аналог .proto в gRPC — источник правды, по которому проверяется каждый запрос.
type User {
id: ID!
name: String!
avatarUrl: String
orders: [Order!]!
}
type Order {
id: ID!
status: String!
amount: Int!
}
type Query {
user(id: ID!): User
}
Восклицательный знак ! значит «поле не может быть null». Query — точка входа для чтения.
За каждым полем стоит резолвер — функция, которая знает, как это поле добыть: сходить в базу, вызвать другой сервис, посчитать. Резолвер user достанет пользователя по id; резолвер orders внутри User — подтянет его заказы. Сервер соединяет резолверы по дереву запроса и собирает ответ.
Каждое поле запроса тянет свой резолвер: name и avatarUrl берутся из уже прочитанной строки, orders идёт за данными сам, и у каждого заказа снова свои резолверы.
Query, mutation, subscription
В GraphQL три типа операций.
- Query — чтение. «Дай мне вот эти данные». Не меняет состояние, как
GETв REST. - Mutation — изменение. Создать заказ, отменить, обновить профиль — всё, что меняет данные.
- Subscription — подписка на события. Сервер сам присылает обновления, когда что-то произошло (новое сообщение, смена статуса заказа) — обычно поверх WebSocket.
Разделение нужно не для порядка, а для работы: чтения можно распараллеливать и кэшировать смелее, а изменения выполняются аккуратно и по одному.
Пример: запрос и ответ
Тот самый экран профиля — один запрос вместо трёх:
query {
user(id: "42") {
name
avatarUrl
orders {
status
amount
}
}
}
Ответ приходит ровно той же формы — только запрошенные поля:
{
"data": {
"user": {
"name": "Анна",
"avatarUrl": "https://.../42.png",
"orders": [
{ "status": "paid", "amount": 1990 },
{ "status": "shipped", "amount": 3500 }
]
}
}
}
Ни телефона, ни настроек — их никто не просил. И заказы приехали вместе с пользователем, без второго обращения к серверу. Обе боли REST закрыты одним запросом.
Чем платят за гибкость
Гибкость не бесплатна — она сдвигает сложность на сервер. Прежде чем брать GraphQL, стоит понимать цену.
Кэширование сложнее. REST-GET кэшируется прокси и CDN по URL из коробки: одинаковый адрес — одинаковый ответ. В GraphQL запрос обычно уходит одним POST на один /graphql, тело у всех разное — по URL кэшировать нечего. Приходится кэшировать на уровне приложения или клиента, а это заметно больше работы. Вернуть себе HTTP-кэш всё-таки можно: спецификация разрешает и GET, и если заранее зарегистрировать запросы на сервере, клиент будет слать вместо текста короткий идентификатор — такой адрес кэшируется как обычный REST. Но настраивать это придётся отдельно.
Проблема N+1 на резолверах. Запросили список из 20 пользователей и у каждого — заказы. Наивный резолвер orders сходит в базу 20 раз, по разу на пользователя, — плюс один запрос на сам список. Получаем 21 обращение вместо двух. Стандартное лекарство — dataloader: он собирает все id за один «тик», батчит их в один запрос к базе (WHERE user_id IN (...)) и раскладывает результат обратно. Про природу этой проблемы — в статье про N+1 в Hibernate.
Контроль нагрузки. Раз клиент сам строит запрос, он может построить и очень тяжёлый: глубокую вложенность или список из миллиона элементов. Один кривой запрос способен положить сервер. Поэтому в GraphQL добавляют ограничения — максимальную глубину запроса, лимит сложности, тайм-ауты. В REST такого класса риска почти нет: набор ответов там фиксирован заранее.
Версионирование через эволюцию схемы. В REST плодят /v1, /v2. В GraphQL версий обычно нет: схему развивают на месте — добавляют новые поля, а устаревшие помечают @deprecated, но не удаляют, пока есть клиенты. Это удобно, но требует дисциплины: сломать старый клиент легко, если убрать поле раньше времени.
Ошибки: код всегда 200
Самое важное отличие, которое отменяет привычку ветвиться по коду ответа: GraphQL отвечает 200 OK почти всегда, в том числе когда что-то сломалось. Ошибки живут в теле ответа, в массиве errors.
{
"data": {
"order": {
"id": "ord-42",
"customer": null
}
},
"errors": [
{
"message": "Сервис клиентов недоступен",
"path": ["order", "customer"],
"extensions": { "code": "UPSTREAM_UNAVAILABLE", "retryable": true }
}
]
}
Читается так: заказ получили, клиента получить не смогли, и в path указано, какая именно ветка ответа пустая. Это и есть частичный ответ — законное состояние, а не сбой: половина запроса выполнена.
Отсюда три правила для клиента. Проверять errors всегда, а не только при не-двухсотом коде. Отличать «поле пустое, потому что так есть» от «поле пустое, потому что ошибка» — по наличию записи в errors с соответствующим путём. И не полагаться на код ответа: 400 приходит только при синтаксически неверном запросе, 500 — при падении самого сервера.
Для сервера правило другое: машинный код ошибки кладут в extensions — стандарт этого не требует, но без него клиенту остаётся разбирать человеческое сообщение. Туда же кладут признак повторяемости и идентификатор трассировки. То есть всё то, что в REST лежит в теле ошибки, здесь переезжает в extensions — и это надо описать в контракте, потому что схема этого не описывает.
Пагинация: курсоры, edges и pageInfo
В GraphQL есть устоявшееся соглашение о постраничном выводе, и оно курсорное — то самое, что в REST приходится описывать самому.
query {
orders(first: 20, after: "Y3Vyc29yOjIw") {
edges {
cursor
node { id status totalAmount }
}
pageInfo { hasNextPage endCursor }
}
}
Три элемента: edges (список связей, у каждой свой курсор), node (сам объект) и pageInfo с признаком «есть ещё» и курсором последнего элемента. Выглядит громоздко и решает те же задачи, что курсорная пагинация в REST: страницы не сдвигаются при вставке новых записей, глубина не стоит ничего.
Зачем лишний уровень edges: на связи можно повесить данные об отношении, а не об объекте — когда добавлен в список, кто добавил, какой вес у связи. В простых случаях это ненужная обёртка, и тогда отдают просто список; но если API публичный, соглашение обычно соблюдают целиком, потому что на него рассчитаны клиентские библиотеки.
Общее число записей в это соглашение не входит намеренно — по той же причине, что и в REST: подсчёт стоит столько же, сколько выборка. Если оно нужно, его добавляют отдельным полем и честно предупреждают о цене.
Сохранённые запросы: как вернуть кэш
«Кэширование сложнее» — проблема, у которой есть штатное лекарство, и его стоит назвать.
Обычный запрос идёт POST с телом — значит, не кэшируется ни браузером, ни сетью доставки. Сохранённые запросы переворачивают это: текст запроса заранее известен серверу (его зарегистрировали при сборке клиента или при первом обращении), и клиент присылает только отпечаток запроса плюс переменные. А раз тело маленькое, запрос уходит через GET — и снова становится кэшируемым.
GET /graphql?extensions={"persistedQuery":{"version":1,"sha256Hash":"a4f2…"}}&variables={"id":"ord-42"}
Что это даёт, кроме кэша: размер запроса перестаёт зависеть от размера выборки (полезно для мобильных клиентов), а сервер может разрешить только зарегистрированные запросы — то есть закрыть возможность прислать произвольный дорогой запрос. Для публичного API это часто главный довод: гибкость GraphQL остаётся у своих клиентов, а снаружи доступен фиксированный набор.
Цена: клиент и сервер должны согласовать список запросов при сборке (появляется шаг в конвейере), а отладка усложняется — в журнале виден отпечаток, а не текст.
Как считают сложность запроса
Ограничения «по глубине и сложности» полезны только с числами, а числа берутся из правил.
Глубина — самое простое: сколько уровней вложенности разрешено. order → customer → orders → customer это четыре уровня; предел в 8–10 отсекает рекурсивные запросы, не мешая обычным. Считается статически, до выполнения.
Сложность — оценка стоимости, и её назначают полям вручную. Каждому полю дают вес: скаляр — 0 или 1, поле, требующее отдельного обращения к базе — больше, список — вес элемента, умноженный на запрошенное количество (first). Сумма по дереву запроса и есть его сложность; предел ставят так, чтобы обычные запросы проходили с запасом, а «дай мне тысячу заказов, у каждого клиента, у каждого его заказы» — не проходил.
orders(first: 100) { … } → 100 × (вес заказа)
customer { … } → + 100 × (вес клиента, потому что на каждый заказ)
Отсюда и главный вывод, который не видно из общих слов: сложность считается до выполнения и потому защищает по-настоящему — запрос отклоняется, не начав работу. Таймаут выполнения тоже нужен, но он ловит уже начатое.
Практический порядок внедрения: сначала предел глубины (дешево, ловит грубые случаи), потом предел числа элементов в списках (обязательный first с максимумом), потом сложность с весами. И обязательно — журнал отклонённых запросов, чтобы понять, не режете ли вы своих.
Авторизация: право проверяется в каждом резолвере
В REST право проверяется на ручке: есть доступ к GET /orders/{id} — отдаём весь ответ. В GraphQL клиент сам собирает форму ответа, и поэтому проверка переезжает внутрь: на каждое поле, которое может оказаться чужим.
Пример, на котором это видно: запрос order { id, customer { email, phone } }. Право «видеть заказ» есть, а право «видеть телефон клиента» — нет. Значит, проверка должна стоять на резолвере телефона, а не на входе в запрос. Если её там нет, поле утечёт — и никакая проверка на уровне ручки этого не поймает, потому что ручка одна и общая.
Отсюда три практических следствия, из-за которых авторизация в GraphQL дороже.
Права описывают по полям, а не по операциям. Это больше правил и больше тестов; зато они точнее.
Ошибка доступа частична. Правильный ответ — отдать то, что можно, и положить в errors запись с путём к запрещённому полю. Отказать всему запросу тоже можно, но это хуже для клиента.
Легко пропустить путь. Одно и то же поле бывает доступно разными путями (order.customer.email и customer.email), и проверка нужна в резолвере поля, а не в резолвере запроса, — иначе один путь закрыт, другой открыт.
Практический вывод: в GraphQL авторизацию проектируют вместе со схемой, а не добавляют после. И проверяют её тестами по полям — в REST для этого хватало тестов по ручкам.
Как это выглядит в Java-проекте
Одной фразой для ориентира: схема лежит ресурсом (*.graphqls), поддержка GraphQL в Spring поднимает одну ручку и сопоставляет поля схемы с методами — тип и поле указываются аннотацией сопоставления (@QueryMapping, @SchemaMapping), а пакетная загрузка связанных сущностей (чтобы не получить N+1 по резолверам) делается загрузчиками, которые собирают ключи и читают их одним запросом. Проблема N+1 здесь ровно та же, что в ORM, и решается тем же приёмом — разбор в статье про N+1.
Где это применяется
GraphQL уместен там, где много разных клиентов с разными потребностями в данных и важна гибкость выборки:
- Мобильные и веб-приложения с богатыми экранами, где каждый экран собирает свою комбинацию полей — и хочется избежать «водопада» запросов на медленной сети.
- Агрегация данных из нескольких источников за одним фасадом: клиент делает один запрос, а сервер сам ходит в разные сервисы и базы.
- Быстро меняющийся фронтенд: новые экраны просят новые срезы данных, не дожидаясь новых endpoint'ов от бэкенда.
Где GraphQL — не лучший выбор:
- Простое CRUD-API с предсказуемыми ответами — REST будет проще и дешевле, а бесплатное HTTP-кэширование останется при вас.
- Внутренние высоконагруженные вызовы между сервисами, где важны скорость и строгий контракт, — там уместнее gRPC.
- Файлы, выгрузки, потоковая отдача — не сильная сторона GraphQL.
Где спотыкаются начинающие
- Забывают про N+1. Схема красивая, запрос элегантный, а под капотом сотни обращений к базе. Dataloader (батчинг) — не опция, а норма для любых списков со вложенностью.
- Оставляют запросы без ограничений. Без лимита глубины и сложности один тяжёлый клиентский запрос кладёт сервер. Ограничения ставят сразу, а не после первого инцидента.
- Ждут «бесплатного» кэша как в REST. По одному
POSTна/graphqlHTTP-кэш не работает; кэширование надо продумывать отдельно. - Удаляют поля из схемы сгоряча. Клиент, который его запрашивал, немедленно ломается. Сначала
@deprecated, потом — только когда никто не пользуется. - Тащат GraphQL в простое API «потому что модно», получая сложность резолверов и потерю HTTP-кэша там, где хватило бы REST.
Глубже: REST, gRPC, GraphQL или вебхук: одна таблица решенийрасширенное
Каждая статья раздела честно говорит, когда её стиль не брать, а собирать выбор приходится читателю. Вот он в одном месте. Вопросы задают по порядку, и первый же уверенный ответ обычно решает.
| Вопрос | REST | gRPC | GraphQL | Вебхук |
|---|---|---|---|---|
| Кто клиент | браузер, партнёры, кто угодно | свои сервисы | свои фронтенды с разными экранами | чужой сервер, который ждёт событий |
| Кто инициатор | клиент спрашивает | клиент спрашивает | клиент спрашивает | вы сообщаете |
| Форма данных | фиксированная на ресурс | фиксированная на метод | клиент выбирает поля | фиксированная на событие |
| Много вызовов на экран | плохо, нужны агрегирующие ручки | нормально, вызовы дешёвые | решается одним запросом | не про это |
| Нужен HTTP-кэш и CDN | да, GET кэшируется | нет | нет, один POST | нет |
| Нужен стриминг | SSE или WebSocket рядом | встроен, в обе стороны | подписки поверх WebSocket | это и есть поток событий |
| Строгий контракт и генерация | OpenAPI, по желанию | обязательно, .proto | схема, обязательно | схема события в OpenAPI |
| Отладка «на коленке» | curl и глаза | grpcurl, бинарный формат | консоль GraphiQL | журнал доставок |
| Ошибки | HTTP-статус и problem+json | grpc-status и Status.details | 200 и массив errors | ответ получателя и повторы |
| Цена | самая низкая | генерация и прокси для браузера | резолверы, N+1, лимиты сложности | очередь, повторы, подпись |
Как этим пользоваться. Публичный контур почти всегда REST: его умеют все, он кэшируется, у него понятные ошибки. Внутри системы между своими сервисами, где вызовов много и важен строгий контракт, gRPC. Фасад для нескольких фронтендов с разными экранами, которые собирают разные срезы одних данных, GraphQL, и он живёт поверх REST или gRPC внутренних сервисов, а не вместо них. Событие, о котором надо сообщить чужой системе, вебхук, и рядом с ним выгрузка по курсору для догона.
Стили сочетаются, а не исключают друг друга: типичная система это REST наружу, gRPC внутри, вебхуки к партнёрам и, если фронтендов больше одного, GraphQL-фасад перед REST. Ошибка не в выборе одного из них, а в попытке сделать один стиль ответом на все четыре вопроса: gRPC для браузера, GraphQL для двух вызовов в секунду между сервисами, опрос вместо вебхука для сорока тысяч интеграций.
Коротко
- GraphQL решает over-fetching и under-fetching: один endpoint, клиент называет форму данных, сервер собирает через резолверы.
- Плата: N+1 без батчинга, лимиты глубины и сложности обязательны, HTTP-кэш не работает, ошибки приходят с
200в массивеerrors. - Место GraphQL это фасад для нескольких фронтендов поверх внутренних сервисов, а не замена REST для CRUD.
- Выбор стиля по вопросам: кто клиент и инициатор, форма данных, число вызовов на экран, кэш, стриминг, контракт; REST наружу, gRPC внутри, GraphQL как фасад, вебхук для событий чужим системам.
- GraphQL отвечает
200даже на сбой: ошибки лежат вerrorsс путём к пустой ветке, частичный ответ — норма, а машинный код и признак повторяемости кладут вextensions. - Пагинация курсорная по соглашению (
edges,node,pageInfo), общее число в него не входит намеренно; сохранённые запросы возвращают кэш и позволяют разрешить только зарегистрированные запросы. - Сложность считают до выполнения: вес поля, умноженный на запрошенное количество, плюс предел глубины и обязательный максимум в списках.
- Авторизация переезжает в резолверы полей: право проверяется на каждое поле и на каждый путь к нему, а отказ отдают частичным ответом.
Что почитать дальше
- gRPC: соседний стиль для внутренних вызовов, где важнее скорость и строгий контракт, а не выбор полей.
- REST: точка отсчёта, от которой стоит отталкиваться, прежде чем брать GraphQL.
- HATEOAS: ветка REST, где клиента избавляют от знания путей ссылками, а не схемой.
- Базы данных: резолверы упираются в базу, там и считается цена N+1.
- Таймауты, повторы и идемпотентность: надёжность вызовов, которая от стиля контракта не зависит.
- Системный дизайн: куда выбор стиля встраивается при проектировании системы целиком.