Published on

GraphQL Federation — Composing a Unified API From Multiple Microservices

Authors

Introduction

GraphQL Federation decouples your API layer from service implementations. Instead of one monolithic schema, each microservice publishes its own subgraph, and a router composes them into a unified API. This enables independent scaling, deployment, and ownership. This post covers subgraph setup with Apollo Federation, @key directives for entity reference, entity resolution, router configuration, avoiding N+1 queries with DataLoader, query planning, cost limits, and distributed tracing.

Apollo Federation Subgraph Setup

Each microservice defines its own schema and publishes a subgraph to the Apollo Router.

// services/posts/schema.ts
import { buildSubgraphSchema } from '@apollo/subgraph';
import { gql, ApolloServer } from 'apollo-server-express';

// Define Post subgraph schema
const typeDefs = gql`
  extend schema @link(url: "https://specs.apollo.dev/federation/v2.0")

  # Reference User from users service
  type User @key(fields: "id") @external {
    id: ID!
    name: String @external
  }

  # Post entity with @key for federation
  type Post @key(fields: "id") {
    id: ID!
    title: String!
    content: String!
    published: Boolean!
    createdAt: DateTime!
    # Field that requires User entity resolution
    author: User!
    tags: [String!]!
    viewCount: Int!
  }

  # Root query
  type Query {
    post(id: ID!): Post
    posts(limit: Int = 20, cursor: String): [Post!]!
    # _entities query for federation
    _entities(representations: [_Any!]!): [_Entity]!
  }

  # Mutation
  type Mutation {
    createPost(input: CreatePostInput!): Post!
    publishPost(id: ID!): Post!
  }

  input CreatePostInput {
    title: String!
    content: String!
    authorId: ID!
    tags: [String!]
  }

  # For federation reference resolution
  union _Entity = Post | User

  scalar DateTime
`;

// Entity resolvers - resolve references from other services
const resolvers = {
  Post: {
    // Reference resolver: given a Post with just id, resolve full Post
    __resolveReference: async (reference: any, context: any) => {
      const post = await context.db.post.findById(reference.id);
      if (!post) {
        throw new Error(`Post ${reference.id} not found`);
      }
      return post;
    },

    // Field resolver for author relation
    author: async (post: any, _args: any, context: any) => {
      return context.userService.getUser(post.authorId);
    },
  },

  User: {
    // External field - resolved by users service
    __resolveReference: async (reference: any) => {
      // This is handled by users service, we just return the reference
      return reference;
    },
  },

  Query: {
    post: async (_parent: any, args: any, context: any) => {
      return context.db.post.findById(args.id);
    },

    posts: async (_parent: any, args: any, context: any) => {
      return context.db.post.findMany({
        limit: args.limit,
        cursor: args.cursor,
      });
    },

    _entities: async (_parent: any, args: any, context: any) => {
      return args.representations.map((rep: any) => {
        if (rep.__typename === 'Post') {
          return context.db.post.findById(rep.id);
        }
        return null;
      });
    },
  },

  Mutation: {
    createPost: async (_parent: any, args: any, context: any) => {
      const post = await context.db.post.create({
        title: args.input.title,
        content: args.input.content,
        authorId: args.input.authorId,
        tags: args.input.tags,
      });
      return post;
    },

    publishPost: async (_parent: any, args: any, context: any) => {
      return context.db.post.update(args.id, { published: true });
    },
  },
};

// Build subgraph schema
const schema = buildSubgraphSchema([{ typeDefs, resolvers }]);

// Create Apollo Server for subgraph
const server = new ApolloServer({
  schema,
  plugins: {
    didResolveOperation(requestContext) {
      console.log(`Resolved operation: ${requestContext.operationName}`);
    },
  },
});

export default server;

Federation Directives: @key and @external

Use @key to identify entities and @external to reference fields from other services.

# posts/schema.graphql

# Identify Post entity by id
type Post @key(fields: "id") {
  id: ID!
  title: String!
  author: User!
}

# userId is external - defined and managed by users service
type Post @key(fields: "id") {
  id: ID!
  # ... other fields
  authorId: ID! # External field from users service
}

# Composite keys for complex entities
type OrderLine @key(fields: "orderId lineNumber") {
  orderId: ID!
  lineNumber: Int!
  product: Product!
  quantity: Int!
}

# Reference User from another service
type User @key(fields: "id") @external {
  id: ID!
  name: String @external # Field we don't own
  email: String @external
}

# Extend User with additional fields from posts service
extend type User @key(fields: "id") {
  id: ID! @external
  posts: [Post!]! # Field defined in posts service
  postCount: Int!
}

# Fragment fields: resolve one entity in terms of another
extend type Post {
  author: User! @requires(fields: "authorId")
}

# Provides: this service calculates a field that user service needs
type User {
  id: ID!
  averageRating: Float @provides(fields: "id")
}

Implement entity reference resolution:

// services/users/resolvers.ts
const resolvers = {
  User: {
    // Required: resolve references from other services
    __resolveReference: async (reference: any, context: any) => {
      // Called when another service references User with { __typename: 'User', id: '123' }
      return context.db.user.findById(reference.id);
    },

    // Calculate derived fields
    averageRating: async (user: any, _args: any, context: any) => {
      const posts = await context.db.post.findByAuthor(user.id);
      const ratings = posts.map((p: any) => p.averageRating);
      return ratings.reduce((a, b) => a + b, 0) / ratings.length || 0;
    },
  },

  Query: {
    user: async (_parent: any, args: any, context: any) => {
      return context.db.user.findById(args.id);
    },

    // Return references for entity batch loading
    _entities: async (_parent: any, args: any, context: any) => {
      return args.representations.map((rep: any) => {
        if (rep.__typename === 'User') {
          // Return reference which triggers __resolveReference
          return rep;
        }
        return null;
      });
    },
  },
};

Entity Resolution and Query Planning

The router plans queries across subgraphs, resolving entities efficiently.

// apollo-router configuration
// router.yaml
supergraph:
  listen: 127.0.0.1:4000

plugins:
  experimental.acl:
    enabled: true

# Entity cache settings
entity_cache:
  in_memory:
    limit: 10000

# Log subgraph requests
apollo:
  logging:
    level: info

# Query planning
query_planning:
  # Cache query plans
  cache:
    redis:
      urls:
        - "redis://localhost:6379"

# Subgraph configuration
subgraphs:
  users:
    routing_url: http://users-service:4001/graphql
    # Define how to batch entity requests
    batch:
      enabled: true
      size: 100

  posts:
    routing_url: http://posts-service:4002/graphql
    batch:
      enabled: true
      size: 100

  comments:
    routing_url: http://comments-service:4003/graphql
    batch:
      enabled: true
      size: 100

Avoiding N+1 Queries with DataLoader

Batch requests to resolve entities efficiently.

import DataLoader from 'dataloader';

// Create DataLoaders for each entity type
class DataLoaders {
  userLoader: DataLoader<string, User>;
  postLoader: DataLoader<string, Post>;
  commentLoader: DataLoader<string, Comment>;

  constructor(db: Database) {
    // Users loader: batches IDs, returns users in order
    this.userLoader = new DataLoader(
      async (ids: readonly string[]) => {
        const users = await db.user.findByIds(Array.from(ids));
        // Return in same order as requested
        return ids.map(id => users.find(u => u.id === id));
      },
      {
        cache: true,
        batchScheduleFn: (callback) => {
          // Batch within single event loop tick
          process.nextTick(callback);
        },
      }
    );

    // Posts loader
    this.postLoader = new DataLoader(
      async (ids: readonly string[]) => {
        const posts = await db.post.findByIds(Array.from(ids));
        return ids.map(id => posts.find(p => p.id === id));
      }
    );

    // Comments loader
    this.commentLoader = new DataLoader(
      async (ids: readonly string[]) => {
        const comments = await db.comment.findByIds(Array.from(ids));
        return ids.map(id => comments.find(c => c.id === id));
      }
    );
  }

  clearAll() {
    this.userLoader.clearAll();
    this.postLoader.clearAll();
    this.commentLoader.clearAll();
  }
}

// Use in resolvers to avoid N+1
const resolvers = {
  Post: {
    author: async (post: any, _args: any, context: any) => {
      // Instead of direct query, use loader
      return context.loaders.userLoader.load(post.authorId);
    },
  },

  User: {
    posts: async (user: any, _args: any, context: any) => {
      // Batch load all posts for all queried users
      const posts = await context.db.post.findByAuthor(user.id);
      return posts;
    },
  },

  Comment: {
    author: (comment: any, _args: any, context: any) => {
      return context.loaders.userLoader.load(comment.authorId);
    },

    post: (comment: any, _args: any, context: any) => {
      return context.loaders.postLoader.load(comment.postId);
    },
  },

  Query: {
    user: (_parent: any, args: any, context: any) => {
      return context.loaders.userLoader.load(args.id);
    },
  },
};

// Create loaders for each request
const server = new ApolloServer({
  schema,
  context: async ({ req }) => ({
    db,
    loaders: new DataLoaders(db),
    userId: req.headers['x-user-id'],
  }),
  plugins: {
    didResolveOperation: async (context) => {
      context.context.loaders.clearAll();
    },
  },
});

Query Planning and Cost Limits

Prevent expensive queries from overwhelming services.

// Query complexity analysis
import {
  GraphQLSchema,
  GraphQLField,
  isObjectType,
  isInterfaceType,
  isUnionType,
} from 'graphql';

class QueryAnalyzer {
  private costMap = new Map<string, number>([
    ['Query.user', 1],
    ['Query.users', 5], // List queries cost more
    ['Post.author', 2], // Cross-service reference
    ['User.posts', 5], // Expensive relation
    ['Post.comments', 3],
  ]);

  analyze(document: any, maxCost: number = 1000): number {
    let totalCost = 0;

    const visit = (node: any, depth: number = 0) => {
      if (node.kind === 'Field') {
        const fieldName = `${node.parentType}.${node.name.value}`;
        const cost = this.costMap.get(fieldName) || 1;

        // Exponential cost for nested queries
        const nestedCost = cost * Math.pow(1.5, depth);
        totalCost += nestedCost;

        if (node.arguments) {
          node.arguments.forEach((arg: any) => {
            if (arg.name.value === 'limit' || arg.name.value === 'first') {
              const limit = arg.value.value;
              totalCost *= Math.min(limit, 100) / 10; // Cap multiplier
            }
          });
        }
      }

      if (node.selectionSet) {
        node.selectionSet.selections.forEach((selection: any) => {
          visit(selection, depth + 1);
        });
      }
    };

    visit(document);

    if (totalCost > maxCost) {
      throw new Error(
        `Query cost ${totalCost} exceeds maximum ${maxCost}`
      );
    }

    return totalCost;
  }
}

// Use in router
const server = new ApolloServer({
  schema,
  plugins: {
    didResolveOperation: async (context) => {
      const analyzer = new QueryAnalyzer();
      const cost = analyzer.analyze(context.document, 1000);
      console.log(`Query cost: ${cost}`);
    },
  },
});

Router Configuration and Federation

Configure Apollo Router to compose subgraphs.

// main.ts - Setup router
import { ApolloGateway, IntrospectAndCompose } from '@apollo/gateway';
import { ApolloServer } from 'apollo-server-express';
import express from 'express';

const gateway = new ApolloGateway({
  supergraphSdl: new IntrospectAndCompose({
    subgraphs: [
      { name: 'users', url: 'http://users-service:4001/graphql' },
      { name: 'posts', url: 'http://posts-service:4002/graphql' },
      { name: 'comments', url: 'http://comments-service:4003/graphql' },
    ],
    pollIntervalInMs: 10000, // Refresh schema every 10s
  }),
  buildService: (subgraph) => {
    // Add auth headers to subgraph requests
    return new DataSourceAPI({
      url: subgraph.url,
      willSendRequest: async ({ request, context }) => {
        request.http?.headers.set(
          'authorization',
          context.req.headers.authorization || ''
        );
      },
    });
  },
  // Enable query planning optimization
  experimental_didResolveQueryPlan: (plan) => {
    console.log('Query plan:', JSON.stringify(plan, null, 2));
  },
});

const server = new ApolloServer({
  gateway,
  context: async ({ req }) => ({
    userId: req.headers['x-user-id'],
  }),
});

const app = express();
app.use(express.json());

server.start().then(() => {
  server.applyMiddleware({ app });
  app.listen(4000, () => {
    console.log('Router listening on :4000');
  });
});

Performance: Observability and Tracing

Trace queries across federated services.

// Distributed tracing with OpenTelemetry
import { NodeSDK } from '@opentelemetry/sdk-node';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
import { JaegerExporter } from '@opentelemetry/exporter-trace-jaeger';

const sdk = new NodeSDK({
  traceExporter: new JaegerExporter({
    host: 'localhost',
    port: 6831,
  }),
  instrumentations: [getNodeAutoInstrumentations()],
});

sdk.start();

// Track federation request timing
const server = new ApolloServer({
  schema,
  plugins: {
    willSendSubgraphRequest: ({ request, subgraphKey }) => {
      const tracer = context.tracer || require('@opentelemetry/api').trace.getTracer('apollo');
      const span = tracer.startSpan(`subgraph.${subgraphKey}`);
      request.http?.headers.set('traceparent', span.spanContext().traceId);
    },

    didResolveOperation: ({ operationName, document }) => {
      console.log(`Operation: ${operationName}`);
      console.log(`Fields: ${extractFieldNames(document)}`);
    },
  },
});

// Query metrics
const metrics = {
  operationCount: 0,
  errorCount: 0,
  averageLatency: 0,
};

server.plugins?.push({
  didResolveOperation: () => {
    metrics.operationCount++;
  },
  didEncounterErrors: () => {
    metrics.errorCount++;
  },
});

Federation vs Schema Stitching

Compare federation approaches for composing schemas.

FeatureFederationSchema Stitching
Entity references@key directiveCustom type merging
Batch entity resolutionBuilt-inManual
Schema compositionCompile-time or runtimeRuntime only
Subgraph discoveryDynamicStatic
PerformanceOptimized for federationCan be slower
Developer experienceBetter toolingMore manual work
Type sharingStrong contractsWeaker contracts

Use Federation for production systems. Use Schema Stitching only for legacy systems difficult to migrate.

Federation Checklist

  • Each service has a @key for entity identity
  • Reference entities use @external for owned-elsewhere fields
  • DataLoaders batch entity resolution to avoid N+1
  • Router configured to introspect subgraph schemas
  • Query cost analysis prevents expensive queries
  • Authentication headers propagated to subgraphs
  • Subgraph errors include proper error codes
  • Query plans cached in Redis
  • Distributed tracing enabled for cross-service visibility
  • Subgraph schemas versioned, backward compatible

Conclusion

GraphQL Federation decouples API composition from service ownership. Each team manages their subgraph independently while the router stitches them into a unified schema. Use @key directives for entity references, DataLoader for efficient batch resolution, and query cost analysis to prevent denial of service. Monitor distributed traces to identify performance bottlenecks across services.