Solving the Unified Graph Problem with GraphQL Federation
Introduction: The Unified Graph Problem
When an organization grows from one GraphQL service to many, a fundamental question emerges: how do clients query data that spans multiple services?
Consider a product detail page that needs:
- Product information (from Product Service)
- Inventory availability (from Inventory Service)
- Pricing with promotions (from Pricing Service)
- Reviews and ratings (from Reviews Service)
- Seller information (from Merchant Service)
- Shipping estimates (from Logistics Service)
Without federation, you have unpleasant choices:
GIF via GIPHY
┌─────────────────────────────────────────────────────────────────────────────┐
│ THE FRAGMENTED GRAPH PROBLEM │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Option 1: Multiple GraphQL Endpoints │
│ ───────────────────────────────────── │
│ Client queries 6 different GraphQL APIs │
│ ❌ Frontend complexity │
│ ❌ No cross-service queries │
│ ❌ No unified type system │
│ │
│ Option 2: Monolithic GraphQL │
│ ──────────────────────────────── │
│ Single GraphQL service that knows everything │
│ ❌ Deployment bottleneck │
│ ❌ Team coupling │
│ ❌ Single point of failure │
│ ❌ Scaling nightmare │
│ │
│ Option 3: BFF Orchestration │
│ ────────────────────────────── │
│ BFF aggregates multiple services │
│ ❌ Duplicates GraphQL features │
│ ❌ BFF becomes bottleneck │
│ ❌ Schema not self-documenting │
│ │
│ Option 4: GraphQL Federation ✓ │
│ ───────────────────────────── │
│ Services contribute to unified supergraph │
│ ✓ Decentralized ownership │
│ ✓ Single client endpoint │
│ ✓ Cross-service queries │
│ ✓ Team autonomy │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
GraphQL Federation solves this by composing multiple service schemas (subgraphs) into a single unified schema (supergraph), with a router that intelligently delegates queries to the appropriate services.
Federation Architecture Overview
┌─────────────────────────────────────────────────────────────────────────────┐
│ GRAPHQL FEDERATION ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Frontend Clients │ │
│ │ Web App Mobile App Partner API Internal Tools │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ Federation Router │ │
│ │ (Apollo Router / Cosmo Router) │ │
│ │ │ │
│ │ • Query planning & execution │ │
│ │ • Schema composition │ │
│ │ • Request routing to subgraphs │ │
│ │ • Response merging │ │
│ │ • Caching & optimization │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────┬───────────────┼───────────────┬──────────┐ │
│ │ │ │ │ │ │
│ ▼ ▼ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Products │ │ Inventory│ │ Pricing │ │ Reviews │ │ Merchants│ │
│ │ Subgraph │ │ Subgraph │ │ Subgraph │ │ Subgraph │ │ Subgraph │ │
│ │ │ │ │ │ │ │ │ │ │ │
│ │ Product │ │ Product │ │ Product │ │ Product │ │ Merchant │ │
│ │ Category │ │ (extend) │ │ (extend) │ │ (extend) │ │ Product │ │
│ │ │ │ Stock │ │ Price │ │ Review │ │ (extend) │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ Each subgraph: │
│ • Owns its domain types │
│ • Extends types from other subgraphs │
│ • Independently deployable │
│ • Team-owned │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
GIF via GIPHY
Federation 2 Core Concepts
Entity Types and Keys
# products-subgraph/schema.graphql
# Products subgraph OWNS the Product type
type Product @key(fields: "id") {
id: ID!
name: String!
description: String!
categoryId: ID!
category: Category!
images: [Image!]!
createdAt: DateTime!
}
type Category @key(fields: "id") {
id: ID!
name: String!
products: [Product!]!
}
type Query {
product(id: ID!): Product
products(filter: ProductFilter, pagination: PaginationInput): ProductConnection!
}
# inventory-subgraph/schema.graphql
# Inventory subgraph EXTENDS Product with inventory data
extend type Product @key(fields: "id") {
id: ID! @external
inventory: Inventory!
inStock: Boolean!
}
type Inventory {
available: Int!
reserved: Int!
warehouse: String!
lastUpdated: DateTime!
}
# pricing-subgraph/schema.graphql
# Pricing subgraph EXTENDS Product with price data
extend type Product @key(fields: "id") {
id: ID! @external
price: Money!
originalPrice: Money
discount: Discount
priceHistory(days: Int = 30): [PricePoint!]!
}
type Money {
amount: Decimal!
currency: Currency!
}
type Discount {
percentage: Float!
validUntil: DateTime
code: String
}
# reviews-subgraph/schema.graphql
# Reviews subgraph EXTENDS Product with review data
extend type Product @key(fields: "id") {
id: ID! @external
reviews(first: Int = 10, after: String): ReviewConnection!
averageRating: Float
ratingDistribution: RatingDistribution!
}
type Review @key(fields: "id") {
id: ID!
author: User!
rating: Int!
title: String!
body: String!
helpful: Int!
verified: Boolean!
createdAt: DateTime!
}
type User @key(fields: "id") {
id: ID!
displayName: String!
}
GIF via GIPHY
The Composed Supergraph
When composed, clients see a unified type:
# Supergraph (composed from all subgraphs)
type Product {
# From products subgraph
id: ID!
name: String!
description: String!
categoryId: ID!
category: Category!
images: [Image!]!
createdAt: DateTime!
# From inventory subgraph
inventory: Inventory!
inStock: Boolean!
# From pricing subgraph
price: Money!
originalPrice: Money
discount: Discount
priceHistory(days: Int = 30): [PricePoint!]!
# From reviews subgraph
reviews(first: Int = 10, after: String): ReviewConnection!
averageRating: Float
ratingDistribution: RatingDistribution!
}
Query Planning and Execution
How the Router Executes Queries
┌─────────────────────────────────────────────────────────────────────────────┐
│ FEDERATED QUERY EXECUTION │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Client Query: │
│ ───────────── │
│ query ProductDetail($id: ID!) { │
│ product(id: $id) { │
│ name # Products subgraph │
│ description # Products subgraph │
│ price { amount } # Pricing subgraph │
│ inStock # Inventory subgraph │
│ reviews(first: 5) { # Reviews subgraph │
│ nodes { rating } │
│ } │
│ } │
│ } │
│ │
│ Query Plan: │
│ ─────────── │
│ │
│ Sequence { │
│ Fetch(products) { │
│ product(id: $id) { id name description } │
│ } │
│ Parallel { │
│ Fetch(pricing) { │
│ _entities(representations: [{__typename: "Product", id: ...}]) { │
│ ... on Product { price { amount } } │
│ } │
│ } │
│ Fetch(inventory) { │
│ _entities(representations: [{__typename: "Product", id: ...}]) { │
│ ... on Product { inStock } │
│ } │
│ } │
│ Fetch(reviews) { │
│ _entities(representations: [{__typename: "Product", id: ...}]) { │
│ ... on Product { reviews(first: 5) { nodes { rating } } } │
│ } │
│ } │
│ } │
│ } │
│ │
│ Execution: │
│ ────────── │
│ 1. Fetch product from Products (100ms) │
│ 2. In parallel: │
│ - Fetch price from Pricing (50ms) │
│ - Fetch inStock from Inventory (30ms) │
│ - Fetch reviews from Reviews (80ms) │
│ 3. Merge responses │
│ │
│ Total: 100ms + max(50ms, 30ms, 80ms) = 180ms │
│ (vs 260ms sequential) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
The _entities Query
GIF via GIPHY
Federation uses a special _entities query for cross-subgraph resolution:
// Subgraph resolver for _entities
const resolvers = {
Query: {
_entities: (_, { representations }) => {
return representations.map(ref => {
switch (ref.__typename) {
case 'Product':
return productLoader.load(ref.id);
case 'User':
return userLoader.load(ref.id);
default:
return null;
}
});
},
},
};
Subgraph Implementation
Products Subgraph (Apollo Server)
// products-subgraph/src/index.ts
import { ApolloServer } from '@apollo/server';
import { buildSubgraphSchema } from '@apollo/subgraph';
import { gql } from 'graphql-tag';
import DataLoader from 'dataloader';
const typeDefs = gql`
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.3",
import: ["@key", "@shareable"])
type Product @key(fields: "id") {
id: ID!
name: String!
description: String!
sku: String!
categoryId: ID!
category: Category!
images: [Image!]!
attributes: [ProductAttribute!]!
createdAt: DateTime!
updatedAt: DateTime!
}
type Category @key(fields: "id") {
id: ID!
name: String!
slug: String!
parentId: ID
parent: Category
children: [Category!]!
products(first: Int, after: String): ProductConnection!
}
type Query {
product(id: ID!): Product
productBySku(sku: String!): Product
products(filter: ProductFilter, pagination: PaginationInput): ProductConnection!
categories: [Category!]!
}
`;
const resolvers = {
Query: {
product: async (_, { id }, { dataSources }) => {
return dataSources.products.getById(id);
},
products: async (_, { filter, pagination }, { dataSources }) => {
return dataSources.products.list(filter, pagination);
},
},
Product: {
// Reference resolver for federation
__resolveReference: async (ref, { dataSources }) => {
return dataSources.products.getById(ref.id);
},
category: async (product, _, { dataSources }) => {
return dataSources.categories.getById(product.categoryId);
},
},
Category: {
__resolveReference: async (ref, { dataSources }) => {
return dataSources.categories.getById(ref.id);
},
products: async (category, { first, after }, { dataSources }) => {
return dataSources.products.listByCategory(category.id, { first, after });
},
},
};
// DataLoader for batching
class ProductsDataSource {
private loader: DataLoader<string, Product>;
constructor(private db: Database) {
this.loader = new DataLoader(async (ids) => {
const products = await this.db.products.findMany({
where: { id: { in: ids as string[] } },
});
return ids.map(id => products.find(p => p.id === id) || null);
});
}
async getById(id: string): Promise<Product | null> {
return this.loader.load(id);
}
async list(filter: ProductFilter, pagination: PaginationInput): Promise<ProductConnection> {
// Implementation
}
}
const server = new ApolloServer({
schema: buildSubgraphSchema({ typeDefs, resolvers }),
});
Inventory Subgraph
GIF via GIPHY
// inventory-subgraph/src/index.ts
const typeDefs = gql`
extend schema
@link(url: "https://specs.apollo.dev/federation/v2.3",
import: ["@key", "@external", "@requires"])
extend type Product @key(fields: "id") {
id: ID! @external
inventory: Inventory!
inStock: Boolean!
availableQuantity: Int!
}
type Inventory {
available: Int!
reserved: Int!
incoming: Int!
warehouse: Warehouse!
lastUpdated: DateTime!
}
type Warehouse {
id: ID!
name: String!
location: String!
}
type Mutation {
reserveInventory(productId: ID!, quantity: Int!): ReservationResult!
releaseReservation(reservationId: ID!): Boolean!
}
`;
const resolvers = {
Product: {
__resolveReference: async (ref, { dataSources }) => {
// Only resolve inventory-specific fields
const inventory = await dataSources.inventory.getByProductId(ref.id);
return {
...ref,
inventory,
inStock: inventory.available > 0,
availableQuantity: inventory.available,
};
},
inventory: async (product, _, { dataSources }) => {
return dataSources.inventory.getByProductId(product.id);
},
inStock: async (product, _, { dataSources }) => {
const inventory = await dataSources.inventory.getByProductId(product.id);
return inventory.available > 0;
},
},
Mutation: {
reserveInventory: async (_, { productId, quantity }, { dataSources }) => {
return dataSources.inventory.reserve(productId, quantity);
},
},
};
Frontend Integration
Apollo Client with Federation
// Frontend Apollo Client setup
import {
ApolloClient,
InMemoryCache,
ApolloLink,
HttpLink,
} from '@apollo/client';
import { onError } from '@apollo/client/link/error';
import { RetryLink } from '@apollo/client/link/retry';
// Single endpoint - router handles federation
const httpLink = new HttpLink({
uri: '/graphql', // Points to federation router
credentials: 'include',
});
// Error handling for federated errors
const errorLink = onError(({ graphQLErrors, networkError, operation }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, locations, path, extensions }) => {
// Federation-specific errors
if (extensions?.code === 'DOWNSTREAM_SERVICE_ERROR') {
console.error(`Subgraph error: ${extensions.serviceName}`);
// Could show degraded UI for that service's data
}
console.error(
`[GraphQL error]: Message: ${message}, Path: ${path}, Service: ${extensions?.serviceName}`
);
});
}
});
// Retry for transient failures
const retryLink = new RetryLink({
delay: {
initial: 300,
max: 3000,
jitter: true,
},
attempts: {
max: 3,
retryIf: (error, operation) => {
// Retry network errors
if (error.networkError) return true;
// Don't retry mutations
if (operation.query.definitions.some(
d => d.kind === 'OperationDefinition' && d.operation === 'mutation'
)) {
return false;
}
return false;
},
},
});
// Cache configuration for federated types
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
product: {
// Cache by ID
read(_, { args, toReference }) {
return toReference({ __typename: 'Product', id: args?.id });
},
},
},
},
Product: {
keyFields: ['id'],
fields: {
// Merge incoming reviews with existing
reviews: {
keyArgs: false,
merge(existing, incoming, { args }) {
if (!existing) return incoming;
return {
...incoming,
nodes: [...existing.nodes, ...incoming.nodes],
};
},
},
},
},
},
});
export const client = new ApolloClient({
link: ApolloLink.from([errorLink, retryLink, httpLink]),
cache,
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-and-network',
},
},
});
Query Patterns for Federation
GIF via GIPHY
// Product detail query
const PRODUCT_DETAIL_QUERY = gql`
query ProductDetail($id: ID!) {
product(id: $id) {
# Core product data (Products subgraph)
id
name
description
images {
url
alt
}
category {
id
name
slug
}
# Pricing data (Pricing subgraph)
price {
amount
currency
}
originalPrice {
amount
}
discount {
percentage
validUntil
}
# Inventory data (Inventory subgraph)
inStock
availableQuantity
inventory {
warehouse {
name
location
}
}
# Review summary (Reviews subgraph)
averageRating
ratingDistribution {
oneStar
twoStar
threeStar
fourStar
fiveStar
}
}
}
`;
// Separate query for reviews (lazy loaded)
const PRODUCT_REVIEWS_QUERY = gql`
query ProductReviews($id: ID!, $first: Int!, $after: String) {
product(id: $id) {
id
reviews(first: $first, after: $after) {
nodes {
id
rating
title
body
author {
displayName
}
helpful
verified
createdAt
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`;
// Component using both queries
function ProductDetailPage({ productId }: { productId: string }) {
// Main product data - loads immediately
const { data, loading, error } = useQuery(PRODUCT_DETAIL_QUERY, {
variables: { id: productId },
});
// Reviews - lazy loaded when user scrolls to reviews section
const [loadReviews, { data: reviewsData, loading: reviewsLoading }] = useLazyQuery(
PRODUCT_REVIEWS_QUERY,
{ variables: { id: productId, first: 10 } }
);
// Intersection observer to trigger review loading
const reviewsSectionRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadReviews();
observer.disconnect();
}
},
{ threshold: 0.1 }
);
if (reviewsSectionRef.current) {
observer.observe(reviewsSectionRef.current);
}
return () => observer.disconnect();
}, [loadReviews]);
if (loading) return <ProductSkeleton />;
if (error) return <ErrorState error={error} />;
const { product } = data;
return (
<div>
<ProductHeader product={product} />
<ProductImages images={product.images} />
<ProductPricing
price={product.price}
originalPrice={product.originalPrice}
discount={product.discount}
/>
<InventoryStatus
inStock={product.inStock}
quantity={product.availableQuantity}
/>
<RatingSummary
averageRating={product.averageRating}
distribution={product.ratingDistribution}
/>
<div ref={reviewsSectionRef}>
{reviewsLoading ? (
<ReviewsSkeleton />
) : reviewsData ? (
<ReviewsList
reviews={reviewsData.product.reviews.nodes}
hasMore={reviewsData.product.reviews.pageInfo.hasNextPage}
onLoadMore={() => {/* Load more logic */}}
/>
) : (
<ReviewsPlaceholder />
)}
</div>
</div>
);
}
Handling Partial Failures
Federation Error Handling
// Router configuration for partial responses
// apollo-router.yaml
supergraph:
introspection: true
listen: 0.0.0.0:4000
include_subgraph_errors:
all: true # Include subgraph errors in response
# Enable partial responses
preview_defer_support: true
plugins:
experimental.expose_query_plan: true
GIF via GIPHY
// Frontend: Handle partial data
const PRODUCT_WITH_OPTIONAL_FIELDS = gql`
query ProductWithOptionalFields($id: ID!) {
product(id: $id) {
id
name
# These might fail if inventory service is down
... @defer(label: "inventory") {
inStock
availableQuantity
}
# These might fail if pricing service is down
... @defer(label: "pricing") {
price {
amount
currency
}
}
# Non-critical, load last
... @defer(label: "reviews") {
averageRating
reviews(first: 3) {
nodes {
rating
title
}
}
}
}
}
`;
// Hook for handling incremental delivery
function useIncrementalQuery(query: DocumentNode, options: QueryOptions) {
const [result, setResult] = useState<{
data: any;
loading: Record<string, boolean>;
errors: Record<string, Error>;
}>({
data: null,
loading: {},
errors: {},
});
useEffect(() => {
const subscription = client.subscribe({
query,
...options,
}).subscribe({
next: (response) => {
if (response.hasNext) {
// Incremental update
const label = response.label;
setResult(prev => ({
data: deepMerge(prev.data, response.data),
loading: { ...prev.loading, [label]: false },
errors: prev.errors,
}));
} else {
// Final response
setResult(prev => ({
...prev,
data: deepMerge(prev.data, response.data),
}));
}
},
error: (error) => {
// Handle error
},
});
return () => subscription.unsubscribe();
}, [query, options]);
return result;
}
// Component using incremental loading
function ProductCard({ productId }: { productId: string }) {
const { data, loading, errors } = useIncrementalQuery(
PRODUCT_WITH_OPTIONAL_FIELDS,
{ variables: { id: productId } }
);
if (!data?.product) return <ProductSkeleton />;
return (
<div>
<h1>{data.product.name}</h1>
{/* Price - show skeleton while loading, fallback on error */}
{loading.pricing ? (
<PriceSkeleton />
) : errors.pricing ? (
<span>Price unavailable</span>
) : (
<Price value={data.product.price} />
)}
{/* Inventory - show skeleton while loading, hide on error */}
{loading.inventory ? (
<StockSkeleton />
) : !errors.inventory && (
<StockBadge inStock={data.product.inStock} />
)}
{/* Reviews - completely optional */}
{!loading.reviews && !errors.reviews && data.product.averageRating && (
<RatingStars rating={data.product.averageRating} />
)}
</div>
);
}
Schema Evolution in Federation
Safe Schema Changes
# ✓ SAFE: Adding a new field
extend type Product @key(fields: "id") {
id: ID! @external
price: Money!
priceHistory(days: Int = 30): [PricePoint!]! # NEW - safe to add
}
# ✓ SAFE: Adding a new nullable argument with default
type Query {
products(
filter: ProductFilter,
sort: SortOrder = RELEVANCE # NEW - safe with default
): [Product!]!
}
# ⚠️ BREAKING: Removing a field
extend type Product @key(fields: "id") {
id: ID! @external
price: Money!
# salePrice: Money # REMOVED - will break clients using this
}
# ⚠️ BREAKING: Changing field type
extend type Product @key(fields: "id") {
id: ID! @external
price: Decimal! # WAS: Money! - breaking change
}
Federation-Specific Evolution
GIF via GIPHY
# Moving a field between subgraphs
# BEFORE: Price in products subgraph
# products-subgraph
type Product @key(fields: "id") {
id: ID!
name: String!
price: Money! # To be moved
}
# MIGRATION STEP 1: Both subgraphs provide the field
# products-subgraph
type Product @key(fields: "id") {
id: ID!
name: String!
price: Money! @shareable @deprecated(reason: "Moving to pricing subgraph")
}
# pricing-subgraph
extend type Product @key(fields: "id") {
id: ID! @external
price: Money! @shareable # New source
}
# MIGRATION STEP 2: Remove from original (after clients migrate)
# products-subgraph
type Product @key(fields: "id") {
id: ID!
name: String!
# price removed
}
Performance Optimization
Entity Caching
// Router-level entity caching
// apollo-router.yaml
supergraph:
listen: 0.0.0.0:4000
cache:
# Enable entity caching
entities:
enabled: true
# Redis for distributed caching
redis:
urls: ["redis://redis:6379"]
ttl: 300s
# Cache specific types
types:
Product:
ttl: 60s
vary:
- headers: ["Authorization"]
Category:
ttl: 3600s # Categories change rarely
User:
ttl: 0s # Never cache user data
Subgraph Batching
// Configure subgraph batching in router
// apollo-router.yaml
traffic_shaping:
subgraphs:
products:
# Batch requests to products subgraph
experimental_batching:
enabled: true
mode: batch_http_link
inventory:
experimental_batching:
enabled: true
mode: batch_http_link
GIF via GIPHY
Query Deduplication
// Subgraph DataLoader for request batching
class ProductsResolver {
private productLoader: DataLoader<string, Product>;
private productsForCategoryLoader: DataLoader<string, Product[]>;
constructor(private db: Database) {
// Batch individual product lookups
this.productLoader = new DataLoader(
async (ids: readonly string[]) => {
const products = await this.db.products.findMany({
where: { id: { in: [...ids] } },
});
return ids.map(id => products.find(p => p.id === id));
},
{ cache: true } // Cache within request
);
// Batch products by category
this.productsForCategoryLoader = new DataLoader(
async (categoryIds: readonly string[]) => {
const products = await this.db.products.findMany({
where: { categoryId: { in: [...categoryIds] } },
});
return categoryIds.map(catId =>
products.filter(p => p.categoryId === catId)
);
}
);
}
async getById(id: string): Promise<Product | null> {
return this.productLoader.load(id);
}
async getByIds(ids: string[]): Promise<(Product | null)[]> {
return this.productLoader.loadMany(ids);
}
async getByCategoryId(categoryId: string): Promise<Product[]> {
return this.productsForCategoryLoader.load(categoryId);
}
}
Monitoring and Observability
Distributed Tracing
// Subgraph with tracing
import { ApolloServerPluginUsageReporting } from '@apollo/server/plugin/usageReporting';
import { trace, context } from '@opentelemetry/api';
const server = new ApolloServer({
schema: buildSubgraphSchema({ typeDefs, resolvers }),
plugins: [
// Apollo Studio reporting
ApolloServerPluginUsageReporting({
sendHeaders: { all: true },
sendVariableValues: { all: true },
}),
// Custom tracing plugin
{
async requestDidStart({ request }) {
const span = trace.getTracer('products-subgraph').startSpan('graphql.request');
return {
async willSendResponse({ response }) {
span.setAttribute('graphql.operationName', request.operationName || 'unknown');
span.end();
},
async didEncounterErrors({ errors }) {
span.setStatus({ code: 2, message: errors[0]?.message });
errors.forEach((err, i) => {
span.recordException(err);
});
},
};
},
},
],
});
Federation-Specific Metrics
GIF via GIPHY
// Router metrics to track
const FEDERATION_METRICS = {
// Query plan metrics
'apollo.router.query_planning.duration': 'histogram',
'apollo.router.query_planning.cache_hit': 'counter',
// Subgraph fetch metrics
'apollo.router.subgraph_request.duration': 'histogram',
'apollo.router.subgraph_request.error': 'counter',
// Entity resolution metrics
'apollo.router.entity_cache.hit': 'counter',
'apollo.router.entity_cache.miss': 'counter',
// Response composition metrics
'apollo.router.response.size': 'histogram',
'apollo.router.response.errors': 'counter',
};
// Frontend: Track federation-related performance
function useTrackFederatedQuery(operationName: string) {
return {
onCompleted: (data: unknown, timing: { startTime: number }) => {
const duration = Date.now() - timing.startTime;
// Track total query time
analytics.timing('graphql.query.duration', duration, {
operation: operationName,
});
},
onError: (error: ApolloError) => {
// Track which subgraph failed
error.graphQLErrors?.forEach(err => {
if (err.extensions?.serviceName) {
analytics.increment('graphql.subgraph.error', {
service: err.extensions.serviceName,
code: err.extensions.code,
});
}
});
},
};
}
Production Incidents and Lessons
Incident 1: N+1 Queries Across Subgraphs
Scenario: Listing page made 1 products query + 100 entity queries to inventory.
Root cause: No batching in _entities resolver.
Fix: Implement DataLoader in all subgraphs for entity resolution.
Incident 2: Schema Composition Failure in Production
Scenario: Deploy broke supergraph because subgraph change was incompatible.
Root cause: No schema checks in CI.
GIF via GIPHY
Fix: Add rover subgraph check to CI pipeline before deploy.
Incident 3: Slow Query Due to Waterfall
Scenario: Query took 3 seconds because fields created dependency chain.
Root cause: Field required data from another field, creating serial execution.
Fix: Restructure schema to allow parallel resolution.
Conclusion
GraphQL Federation enables organizations to scale their GraphQL APIs by distributing ownership across teams while providing a unified interface to clients. For frontend engineers, federation is largely transparent—they query a single supergraph and let the router handle distribution.
Key takeaways:
- Unified client experience: Clients see one schema, regardless of how many services implement it
- Incremental delivery: Use
@deferto load data progressively - Partial failure handling: Design UIs that gracefully handle missing data
- Caching: Leverage entity caching for performance
- Observability: Track subgraph-level metrics for debugging
GIF via GIPHY
Federation complexity is hidden from clients—that's the point. Frontend engineers benefit from the unified schema without needing to understand the distributed implementation.
Further Reading
- "Apollo Federation Documentation"
- "Scaling GraphQL at Netflix" (Netflix Tech Blog)
- "GraphQL Federation at Expedia" (QCon Talk)
- "The Guild's GraphQL Mesh and Federation"
- "Federation 2 Specification"
GIF via GIPHYWhat did you think?