Глубокая вложенность это обещание GraphQL и его проблема: клиент собирает запрос на пять этажей связей, и наивный резолвер исполняет его честно, то есть ходит в источник по каждому полю отдельно. Страница со ста авторами и десятью постами у каждого оборачивается в тысячу обращений к базе, и красиво декларированная гибкость мгновенно превращается в атаку на собственный сервер.

Борьба с вложенностью это половина ремесла бэкенда на GraphQL. Ниже разобраны причины квадратичного роста обращений, типы резолверов и их зоны ответственности, батчинг загрузчиков, лимиты глубины и сложности, типизация резолверов и ошибки частичных ответов. С кодом, числами и правилами, которые спасают продакшен до первого инцидента, а не после.

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

Резолвер как функция одного поля и цена наивной вложенности

Резолвер отвечает ровно за одно поле: получает родительский объект, аргументы, контекст и сведения об исполнении и возвращает значение поля. Чистота этой абстракции обманчива: резолвер для списка авторов возвращает список, резолвер для постов каждого автора вызывается отдельно, и глубина запроса умножает число вызовов.

Классическая ловушка носит имя проблемы плюс один: запрос списка это одно обращение, а вложенные поля по каждому элементу это ещё по одному на элемент, и стоимость растёт произведением. Сто элементов с тремя связями это четыреста обращений к источнику, и рост нагрузки на порядок при росте аудитории в полтора раза ощущается как несправедливость, хотя это арифметика.

Диагноз ставится логами: счётчик обращений к источнику на один запрос показывает множитель честно. Локальная разработка с тремя авторами молчит, продакшен с тысячью не молчит никогда, и счётчик обязан появиться до первого релиза, потому что после релиза он появляется сам, только в виде алармов и жалоб.

Отсюда правило проектирования: резолверы остаются тонкими, вся работа с источниками живёт в слоях загрузки, и вложенность обслуживается механизмами, о которых дальше.

Профилирование исполняется на тяжёлом запросе с журналом резолверов: карта вызовов с временем показывает, что тормозит, сама вложенность или один тяжёлый лист, и лечение выбирается по диагнозу, а не по привычке.

Загрузчики и батчинг как свернуть сотню обращений в три

Загрузчик решает проблему плюс один скромно и гениально: вместо обращения на каждый элемент он копит запросы за один такт цикла и выполняет их одним пакетом. Резолверы автора сотни постов синхронно просят загрузчик о ключах, загрузчик собирает сто ключей, ходит в источник один раз списком и раздаёт результаты по обещаниям.

const userLoader = new DataLoader((ids) => {
  return db.users.findMany({ where: { id: { in: ids } } })
    .then((rows) => ids.map((id) => rows.find((r) => r.id === id)));
});

Правило жизни загрузчика это один запрос: создаётся на контексте исполнения, живёт в нём, умирает с ним. Глобальный загрузчик живёт между запросами и дарит соседям чужие правки и чужие права, и это не оптимизация, а дыра. Кэш внутри загрузчика честен в пределах одного запроса и снимает повторы одного ключа, а полноценное кэширование между запросами живёт отдельным слоем со своими правилами времени жизни.

Порядок в пакетах требует дисциплины: источник обязан вернуть записи в порядке ключей или загрузчик обязан их расставить, иначе посты авторов тихо перемешаются, и никто не заметит до первого разбирательства.

Загрузчики описываются рядом со схемой и покрываются тестами на порядок ключей: тест из пяти ключей с перемешанным источником ловит главную ошибку батчинга за минуту и окупается навсегда. Пустые значения обрабатываются явно: отсутствующий ключ это пустое значение, а не ошибка, и смешение этих понятий ломает интерфейсы списков.

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

Инвалидация между запросами заслуживает отдельного решения: кэш с временем жизни годится для справочников и почти никогда для живых правок. Честный путь это ключ с версионированием сущностей, где правка поднимает версию, а следующий запрос берёт свежее, и такие механизмы вводятся по конкретным горячим местам, а не повсеместно.

Ограничение глубины и сложности запроса до того как клиент утомит сервер

Свобода клиента собирать запросы обязана упираться в забор. Лимит глубины отсекает башни из десяти этажей, которых не писал ни один разумный интерфейс, и разумный порог измеряется единицами. Лимит сложности считает условную стоимость полей и отсекает запросы, честно исполнять которые дороже, чем стоит ответ.

Лимиты проверяются до исполнения, на этапе валидации документа, и отказ приходит быстро и дёшево, а не после десяти секунд нагрузки. Сообщение отказа обязано называть правило и число, чтобы автор запроса мог сузить выбор полей без переписки с поддержкой.

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

Статистика отказов по лимитам сама подсказывает политику: частые отказы одного клиента это недокументированный сценарий, единичные отказы это нормальная цена защиты, и график отказов по версиям клиентов показывает, где документация отстала от жизни.

Персистентные запросы завершают забор: клиент регистрирует текст запроса заранее, сервер исполняет по хешу, и произвольная глубина в продакшен просто не доезжает.

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

Типизация резолверов контракты и кодогенерация вместо ручного дрейфа

Ручное описание сигнатур резолверов дрейфует от схемы: поле переименовали, тип поменяли, а сигнатура в коде осталась прежней, и ошибка вылезает в рантайме. Кодогенерация снимает дрейф: из схемы генерируются типы аргументов, родительских объектов и результата, и расхождение ловится компилятором до запуска.

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

Валидация аргументов делегируется схеме: типы и ограничения описываются декларативно, и резолвер получает уже проверенные значения. Бизнес-правила, не выражаемые типами, живут на входе резолвера явными проверками, и границы ответственности читаются по слоям.

Документация исполняет роль тестов для интеграций: описания полей, примеры значений и сообщения об ошибках попадают в схему и путешествуют с ней в любые инструменты. Резолвер без описания поля это подарок будущему разбору в три часа ночи.

Устаревание полей ведётся пометками в схеме: поле помечается, клиенты видят предупреждение в инструментах, и через объявленный период поле уходит. Команда читает пометки как список работ на ближайший квартал, и миграции проходят без внезапных поломок интеграций.

Слои резолверов и сервисов где живёт бизнес-логика

Резолверы делятся на два сорта: тонкие и толстые. Тонкий резолвер достаёт значение из родительского объекта одной строкой и уходит, толстый оркестрирует: зовёт сервисы, собирает контекст решения, проверяет права. Смешение этих ролей внутри одного файла быстро превращает слой резолверов во вторую бизнес-логику, которую никто не может найти.

Здоровая рамка проста: резолвер валидирует вход, зовёт сервис, формирует ответ и не знает о базе ничего. Сервис владеет правилами и транзакциями, источник владеет доступом к хранилищу. Тогда вложенность обслуживается тонкими резолверами над загрузчиками, а настоящая работа живёт в сервисах с собственными тестами, и изменения правил не требуют правок в двадцати резолверах.

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

События и уведомления живут рядом с сервисами: мутация меняет сущность и публикует событие, резолверы подписок слушают поток и рассылают обновления клиентам. Подписка не тянет из базы в цикле, она слушает поток, и это единственная схема, которая масштабируется на тысячи открытых каналов.

Ошибки и частичные ответы как честно упасть на части вложенного запроса

Вложенный запрос падает по частям: один лист недоступен, остальные приедут. Механика знакома: поле возвращает пустое значение, сообщение уезжает в массив ошибок с путём до поля, и клиент решает, критично ли. Резолверы обязаны пользоваться этой механикой осознанно: ошибка одного комментария не обязана валить всю страницу.

Форматирование ошибок это контракт: код, путь, сообщение без внутренностей.

Обязательность полей проектируется вместе со схемой: поле с допустимым пустым значением это разрешение упасть локально, обязательное поле это обещание, которое резолвер выполнит при любых обстоятельствах. Пересмотр таких обещаний дешевле на дизайне схемы, чем на разборе упавшей страницы в продакшене. Стеки и запросы источника остаются в журнале сервера, клиенту уезжает смысл и код, и это разграничение экономит тонны недоразумений. Журнал обязан содержать путь из запроса, чтобы дежурный находил виновника по первой строке.

Пути до поля в ошибках это не только удобство дежурного: по путям собирается статистика падающих узлов, и одно поле с десятком ошибок в минуту находится по агрегату за вечер, без просмотра всех сообщений руками.

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

Сценарные тесты закрывают вложенность прагматично: пять реальных запросов из продакшена, включая самый тяжёлый, исполняются против тестовых источников, и регрессия по числу обращений ловится счётчиком. Красивые тесты одиночных резолверов не заменяют уродливый тест тяжёлого запроса, потому что проблемы вложенности живут только в связках.

Порядок укрепления вложенности в бэкенде GraphQL

Опыт команд сходится в короткой лестнице, и каждая ступень имеет свою цену и свой срок:

  1. включить счётчик обращений к источникам на один запрос и посмотреть правде в глаза;
  2. перевести связи на загрузчики с жизнью на контексте исполнения;
  3. поставить лимиты глубины и сложности с отказом до исполнения;
  4. договориться с фронтом о порогах и опубликовать их в документации;
  5. сгенерировать типы резолверов из схемы и включить компилятор в конвейер;
  6. оформить формат ошибок контрактом с кодами и путями, замаскировать внутренности;
  7. прогонять пять живых запросов из продакшена в каждом релизе со счётчиком обращений.

Вложенность в GraphQL не побеждают страхом перед гибкостью, её обслуживают: загрузчики сворачивают произведения в суммы, лимиты ставят забор, типизация держит контракт, тесты живых запросов сторожат регрессии. Бэкенд, собранный по этим правилам, отвечает на пятиэтажный запрос за десятки миллисекунд, и клиенту даже не приходит в голову, что эта свобода кому-то стоила дисциплины. Именно поэтому вложенность в зрелых командах считается решённой проблемой, а не полем битвы.