GraphQL-схема растёт как живое существо. В маленьком проекте она помещается в один файл, в среднем проекте её выносят в модули, а в большой компании она превращается в поле споров: команда каталога тянет к товару поле price, команда доставки - поле weight, и все упираются в один тип Product, который принадлежит всем и потому никому. Подход federation переводит конфликт в плоскость договора: каждый сервис объявляет собственный подграф, композиция собирает из них единый суперграф, а клиент по-прежнему отправляет запрос на один адрес и не подозревает, сколько сервисов отработало в ответе. Идея стара как сама распределённость, но GraphQL дал ей точную механику: типы ссылаются друг на друга через сущности, а не через хрупкие идентификаторы. Ниже разбор того, как это устроено, откуда берутся подводные камни и что получает команда, которая переходит с монолитной схемы на федеративную.
Одному клиенту нужен единый граф поверх десятка сервисов
Экран товара в мобильном приложении - это агрегат из пяти миров: название и описание из каталога, цена из коммерции, остатки со склада, отзывы из рейтингового сервиса, срок доставки из логистики. Клиенту всё равно, где живут эти поля, ему нужен один ответ. Путей три. Первый - клиент сам ходит по пяти эндпоинтам: перекрёстные запросы, дублирование логики слияния на каждой платформе, попытка браузера распараллелить всё это и просадка на мобильной сети. Второй - сервер-агрегатор или BFF, отдельный слой, который собирает ответ вручную: он быстро обрастает зависимостями от всех команд и незаметно становится новым монолитом. Третий путь - федеративный граф, где каждая команда владеет своей частью схемы, а снаружи остаётся один вход.
За третьим путём стоит и экономика трафика. Клиент, который сам собирает экран из пяти запросов, платит за каждое рукопожатие отдельно и рисует интерфейс по мере прихода ответов; на нестабильной сети это выглядит как постепенное проявление блоков. Один граф даёт один ответ на один экран, а в свежих версиях роутера медленные ветки отделяют директивой defer, и клиент рисует быструю часть сразу, не дожидаясь самого ленивого поля.
Федеративная модель прижилась в крупных платформах. Netflix в своём фреймворке DGS поддерживает федерацию как один из базовых режимов работы, а экосистема вокруг GraphQL выросла в целую индустрию шлюзов, реестров схем и проверок совместимости. Причина проста: чем больше команд, тем дороже любое централизованное сшивание и тем ценнее автоматический договор, который проверяет машина.
Субграфы и суперграф как договор между командами
Словарь тут короткий. Субграф - обычный GraphQL-сервис, к схеме которого добавили несколько служебных директив. Суперграф - файл, куда композиция складывает все субграфы и соединяет их невидимыми для клиента связками. Внутри суперграфа живёт join-спека: служебные поля и типы, по которым шлюз понимает, в какой субграф отправится каждый фрагмент запроса. Ключевое правило одно: у каждого поля ровно один владелец, спорные случаи оговариваются явно.
Вторая версия Federation, вышедшая в 2022 году, сильно смягчила правила композиции. Раньше совпадение одного поля в двух субграфах считалось ошибкой владения, и команды часами выясняли, кто настоящий хозяин. Теперь value-типы по умолчанию считаются разделяемыми, а конфликт разрешается точечными директивами. Предшественник подхода, схема-ститчинг, где сшивание выполняли руками в рантайме, официально сдал позиции: ручные резолверы-прокладки проигрывают декларативному договору.
Сущности с директивой key строят мосты между командами
Центральная идея федерации - сущность, тип с ключом. Директива @key помечает поля, по которым этот тип можно узнать из любого субграфа. Субграф продуктов объявляет товар так:
type Product @key(fields: "upc") {
upc: String!
name: String
price: Int
inStock: Boolean
}
type Query {
topProducts(first: Int = 5): [Product]
}
Субграф отзывов ничего не знает ни о цене, ни о названии, но хочет ссылаться на тот же товар. Он объявляет расширение сущности и честно помечает чужое поле директивой @external:
type Review {
id: ID!
rating: Int
body: String
product: Product
}
type Product @key(fields: "upc") {
upc: String! @external
reviews: [Review]
}
Клиент пишет обычный запрос и не видит границ:
query {
topProducts(first: 3) {
name
price
reviews {
rating
body
}
}
}
Шлюз исполняет его в два шага. Сначала летит запрос за товарами в субграф продуктов, затем найденные сущности превращаются в представления - короткие объекты с именем типа и ключом - и одним пакетом уходят в субграф отзывов через служебное поле _entities:
query($representations: [_Any!]!) {
_entities(representations: $representations) {
... on Product {
reviews {
rating
body
}
}
}
}
{
"representations": [
{ "__typename": "Product", "upc": "B0007N5NMS" },
{ "__typename": "Product", "upc": "B000Q6RJJ4" },
{ "__typename": "Product", "upc": "B00P0E8750" }
]
}
Три товара - один вызов, а не три. Батчинг представлений встроен в протокол, и это главный ответ тем, кто боится классической проблемы N+1 на межсервисных связях.
Ключ бывает составным: запись @key(fields: "sku warehouse") описывает сущность, уникальную только в паре значений. У типа может быть и несколько ключей, по одному на каждый способ обращения к сущности. Главное требование к ключу - стабильность: значение обязано вычисляться без обращения к соседям и переживать любые рефакторинги домена.
Проверить, что именно отдаёт конкретный субграф, можно и без реестра: каждый из них публикует собственную схему через служебное поле _service:
query {
_service {
sdl
}
}
Ровно это поле использует конфигурация IntrospectAndCompose при старте: шлюз обходит субграфы, вытягивает их схемы и компонозит суперграф на лету. Для продакшена такой режим грубоват, зато он честно экономит силы на первых шагах.
Директивы shareable, override и requires при общих полях
Реальные графики редко такие чистые, как в учебных примерах. Директива @shareable разрешает полю жить сразу в нескольких субграфах: так переносят тривиальные поля вроде name у справочника, где одинаковость гарантируется по смыслу. Директива @override со ссылкой на старый субграф устраивает переезд поля от одной команды к другой без выключения старого владельца. Директива @requires описывает вычисляемое поле, которому для расчёта нужны чужие поля:
type Product @key(fields: "upc") {
upc: String!
weight: Int @external
price: Int @external
shippingEstimate: Int @requires(fields: "weight price")
}
Субграф логистики не хранит ни вес, ни цену, но получает их вместе с представлением сущности и считает оценку доставки. Директива @provides работает в обратную сторону: она подсказывает шлюзу, что субграф уже вернул ключевое поле, и лишний хоп не нужен. Для служебных нужд есть @inaccessible, которое прячет поле из суперграфа, оставляя его внутренним, и @tag, навешивающая метки для политик доступа. Отдельная директива @interfaceObject позволяет субграфу отвечать за интерфейс целиком, разворачивая его в конкретные типы на своей стороне.
Гейтвей или Router исполняют план запроса по субграфам
Шлюз получает суперграф один раз и строит планы запросов на лету: план - это дерево шагов Fetch и Flatten, где параллельные ветки отправляются одновременно. Классическая связка на Node.js выглядит так:
const { ApolloGateway, IntrospectAndCompose } = require("@apollo/gateway");
const gateway = new ApolloGateway({
supergraphSdl: new IntrospectAndCompose({
subgraphs: [
{ name: "products", url: "http://products:4001/graphql" },
{ name: "reviews", url: "http://reviews:4002/graphql" }
]
})
});
Новый роутер от той же экосистемы написан на Rust и забирает у JS-гейтвея роль рекомендуемого рантайма: он грузит суперграф статически, меньше весит и предсказуемо держит нагрузку. Ценой любой федерации остаются хопы: каждый переход между субграфами добавляет сетевую задержку, и глубина цепочки сущностей - главная причина медленных запросов. Практическое правило: четыре уровня вложенности через три субграфа заслуживают отдельного разговора на архитектурном комитете.
Арифметика задержек беспощадна. Если бюджет экрана - 200 миллисекунд, а каждый прыжок между сервисами стоит условные 20-40, то цепочка из четырёх сущностных уровней съедает половину бюджета до того, как отработает хоть один резолвер. Отсюда два приёма: держать глубокие связи внутри одного субграфа, где они обходятся без сети, и полагаться на то, что шлюз кэширует представления сущностей в пределах запроса: три упоминания одного товара в одном запросе не превращаются в три одинаковых вызова.
Сборка суперграфа через Rover и пайплайн композиции
Суперграф собирает утилита Rover по списку субграфов:
federation_version: =2.7
subgraphs:
products:
routing_url: http://products:4001/graphql
reviews:
routing_url: http://reviews:4002/graphql
rover supergraph compose --config supergraph.yaml > supergraph.graphqls
Ровер умеет и проверять, и публиковать схемы в реестр:
rover subgraph check my-graph@prod --name products --schema products.graphql
rover subgraph publish my-graph@prod --name products --schema products.graphql
Команда check сравнивает новую схему с текущим суперграфом и показывает долю операций, которые сломаются; publish обновляет реестр и запускает сборку нового суперграфа. Дальше в игру вступает канареечная выкладка: часть трафика ведут по новому суперграфу, сравнивают задержки и доли ошибок, и только потом переключают всех.
Взрослые команды не собирают суперграф руками на ноутбуке. Пайплайн в непрерывной интеграции проверяет каждую версию субграфа на композиционность с текущими версиями остальных: если новая схема ломает суперграф, падает сборка ветки, а не продакшен. Реестр схем хранит историю, а облачная платформа GraphOS добавляет наблюдаемость: какие поля деградируют, какие операции стали быстрее или медленнее после релиза. Композиция заодно ловит и семантические ошибки, например нигде не разрешимое поле, которое недоступно ни в одном субграфе.
Правила эксплуатации федерации и типичные ошибки
Опыт команд сводится к короткому набору дисциплины. Ключ сущности - это навсегда: ключевые поля не меняют тип и смысл, иначе мосты рушатся у всех соседей сразу. Ключ не собирают из меняющихся величин вроде текущей цены. Заимствованное поле обязательно помечают @external, иначе композиция не поймёт, чьё оно. Циклические зависимости через @requires запрещаются на уровне здравого смысла. Внутренние поля прячут через @inaccessible, а не через комментарии в коде. И последнее: у связных полей должны совпадать аргументы пагинации, иначе клиент получает разные правила игры в одном запросе.
Отдельная дисциплина - наблюдаемость. Планы запросов выгружают в трейсинг: когда операция деградирует, первым делом смотрят, не изменился ли её план, не добавился ли лишний хоп, не переехало ли поле к медленному субграфу. Федерация делает архитектуру прозрачнее ровно до тех пор, пока планы видны инженерам, а не только шлюзу. И финальный совет банален: не заводить сущность ради одного поля. Мосты нужны там, где за ними стоит домен, а не одиночная колонка.
План подключения федерации к зоопарку сервисов обычно укладывается в короткую последовательность шагов:
- выделить домены и раздать владение типами между командами;
- пометить сквозные типы директивой key и согласовать ключевые поля;
- собрать суперграф композиционером и прогнать валидацию;
- поднять Router или гейтвей и направить туда клиентский трафик;
- встроить проверку композиции в непрерывную интеграцию и вести реестр схем;
- следить за планами запросов и задержками субграфов в продакшене.
Федерация не отменяет главного закона распределённых систем: чем больше участников, тем важнее договор и тем строже должен быть его контроль. Но она переводит спор о схеме из чатов и совещаний в машинно проверяемые артефакты. Субграф, суперграф, план запроса - три слова, после которых большая компания перестаёт выбирать между монолитом и хаосом отдельных эндпоинтов, и получает третий путь: один граф, много владельцев и автомат вместо арбитра.