GraphQL Federation #

When a small team manages a single GraphQL schema, everything feels easy — one repo, one deployment, one responsible team. But when an organization grows to dozens of teams with different domains, the monolithic schema becomes a bottleneck: every change requires coordination, one team’s deployment blocks others, and the giant schema gets harder to understand. GraphQL Federation is the answer to this scale problem — a way to split a large schema into subgraphs owned by different teams, while still presenting one unified GraphQL endpoint to clients. This article covers how Federation works, its key directives, query planning, schema governance, and when Federation’s complexity is worth bearing.

The Problems Federation Solves #

A GraphQL monolith works well up to a point. Past that point, problems appear one by one:

GraphQL Monolith in large organizations — the problems that emerge:

1. Deployment bottleneck
   The User Team, Order Team, and Payment Team all must merge into one repo
   → One broken PR blocks all other teams from deploying
   → The release cycle slows down as teams grow

2. Expensive schema coordination
   A type User change by the User Team must be communicated to all teams
   that use or extend the User type
   → Repeated coordination meetings
   → Schema changes become big projects

3. An uncontrollable codebase
   Resolvers for User, Order, Payment, Inventory, Notification all in one file
   → No clear boundaries between domains
   → A bug in one domain can impact the entire schema

4. No clear ownership
   "Who is responsible for this type?"
   "Which team should be pinged when there's an issue with this resolver?"

GraphQL Federation solves this by letting every team own their subgraph — an independent GraphQL service, deployed separately, but contributing to one unified supergraph.


Federation Architecture: Supergraph, Subgraph, and Router #

flowchart TD
    Client["Client\n(Web / Mobile)"]
    Router["Apollo Router / Gateway\n(Supergraph)\nQuery Planning & Orchestration"]

    subgraph UserSG["User Subgraph\n(User Team)"]
        US["User Service\ntype User @key(fields: 'id')\nquery { user(id: ID!): User }"]
    end

    subgraph OrderSG["Order Subgraph\n(Order Team)"]
        OS["Order Service\ntype Order @key(fields: 'id')\ntype User @key(fields: 'id') @extends"]
    end

    subgraph ProductSG["Product Subgraph\n(Product Team)"]
        PS["Product Service\ntype Product @key(fields: 'id')\ntype Order @key(fields: 'id') @extends"]
    end

    subgraph ReviewSG["Review Subgraph\n(Review Team)"]
        RS["Review Service\ntype Review\ntype Product @key(fields: 'id') @extends"]
    end

    Client -->|"POST /graphql"| Router
    Router -->|"Subquery"| UserSG
    Router -->|"Subquery"| OrderSG
    Router -->|"Subquery"| ProductSG
    Router -->|"Subquery"| ReviewSG

    style Router fill:#E67E22,color:#fff,stroke:#D35400
    style Client fill:#2C3E50,color:#fff

Subgraph is a regular GraphQL service with Federation directives added. Each subgraph has its own schema, its own deployment, and its own responsible team. They don’t know each other directly — communication happens through the Router.

Router (Apollo Router or Apollo Gateway) is the component that receives queries from clients, understands the entire supergraph schema, creates a query plan, and distributes subqueries to the relevant subgraphs. Clients only talk to the Router — they don’t know how many subgraphs exist behind it.

Supergraph is the combined schema produced by composing all subgraphs. The Router needs this supergraph schema to create query plans.


Federation Directives — The Key Vocabulary #

Directives are how subgraphs communicate information to the Router about how their types and fields relate to other subgraphs.

@key — Defining Entities #

@key is the most fundamental directive. It marks a type as an entity — a type that other subgraphs can reference using a specific identifier.

# In the User Subgraph
type User @key(fields: "id") {
  id: ID!
  name: String!
  email: String!
  createdAt: String!
}

type Query {
  user(id: ID!): User
  users: [User!]!
}

With @key(fields: "id"), other subgraphs can extend the User type using the id field as the key to find and merge data.

@extends, @external, and @requires #

These three directives work together when one subgraph wants to extend an entity defined in another subgraph.

# In the Order Subgraph
# User is defined in the User Subgraph, but the Order Subgraph wants to add fields

type User @key(fields: "id") @extends {
  id: ID! @external       # this field comes from the User Subgraph
  orders: [Order!]!       # new field provided by the Order Subgraph
}

type Order @key(fields: "id") {
  id: ID!
  total: Float!
  status: OrderStatus!
  user: User!
}

type Query {
  order(id: ID!): Order
}
# In the Review Subgraph with @requires
# Reviews need information from another subgraph for their resolvers

type Product @key(fields: "id") @extends {
  id: ID! @external
  name: String! @external    # needs the product name from the Product Subgraph
  averageRating: Float!      # computed from reviews
    @requires(fields: "name")  # requires 'name' from the Product Subgraph
  reviews: [Review!]!
}

@requires tells the Router: “To resolve this field, I first need the name field from the subgraph that owns Product.” The Router ensures that data is available before calling the Review Subgraph.

@provides — Query Planning Optimization #

@provides is a hint to the Router that this subgraph can provide certain fields of another entity without needing to call the subgraph that owns the entity.

# In the Order Subgraph
type Order @key(fields: "id") {
  id: ID!
  user: User! @provides(fields: "name email")
  # The Order Subgraph stores a denormalized copy of user.name and user.email
  # The Router can skip calling the User Subgraph if only name and email are needed
}

type User @key(fields: "id") @extends {
  id: ID! @external
  name: String! @external
  email: String! @external
}

Query Planning — How the Router Executes Queries #

This is Federation’s most interesting mechanism — the Router doesn’t just proxy requests, it actively creates an optimal query plan.

sequenceDiagram
    participant C as Client
    participant R as Router
    participant US as User Subgraph
    participant OS as Order Subgraph
    participant PS as Product Subgraph

    C->>R: query { user(id: "1") { name orders { total product { name } } } }

    Note over R: The Router creates a query plan:
    Note over R: Step 1: Fetch the user from the User Subgraph
    Note over R: Step 2: Fetch orders from the Order Subgraph (using user.id)
    Note over R: Step 3: Fetch products from the Product Subgraph (using product.id from orders)

    R->>US: query { user(id: "1") { name id } }
    US-->>R: { user: { id: "1", name: "Budi" } }

    R->>OS: query { _entities(representations: [{__typename: "User", id: "1"}]) { ... on User { orders { total product { id } } } } }
    OS-->>R: { orders: [{ total: 150000, product: { id: "p1" } }] }

    R->>PS: query { _entities(representations: [{__typename: "Product", id: "p1"}]) { ... on Product { name } } }
    PS-->>R: { name: "Laptop" }

    Note over R: Merge all results
    R-->>C: { user: { name: "Budi", orders: [{ total: 150000, product: { name: "Laptop" } }] } }

The key to query planning is the _entities query, automatically generated by every subgraph that has entities. The Router uses this to “continue” entity resolution at the right subgraph.


Entity Resolution — How Subgraphs Handle _entities #

Every subgraph defining an entity must implement a reference resolver — a function that receives an entity representation (at minimum its @key fields) and returns complete data.

// User Subgraph — reference resolver
type Resolver struct{}

// The Router calls this when it needs to resolve a User entity
func (r *Resolver) ResolveReference(reference map[string]interface{}) (*User, error) {
    // reference = map[string]interface{}{"__typename": "User", "id": "123"}
    // Fetch data based on the id given by the Router
    return users.FindByID(reference["id"].(string))
}

func (r *Resolver) User(_ interface{}, args struct{ ID string }) (*User, error) {
    return users.FindByID(args.ID)
}

// Order Subgraph — extend User and add the orders field
type OrderResolver struct{}

func (r *OrderResolver) ResolveReference(reference map[string]interface{}) (*User, error) {
    return &User{ID: reference["id"].(string)} // just return the representation
}

func (r *OrderResolver) Orders(user *User) ([]*Order, error) {
    // Fetch all orders belonging to this user
    return orders.FindByUserID(user.ID)
}

Schema Composition and Governance #

One of Federation’s biggest risks is schema conflict — two subgraphs defining the same type in incompatible ways. Schema governance minimizes this risk.

flowchart LR
    subgraph Dev["Development Flow"]
        SGCode["Subgraph Code\n+ Schema Changes"]
        CI["CI Pipeline"]
        SR["Schema Registry\n(Apollo Studio)"]
        Check{"Schema\nCompatibility\nCheck"}
        Deploy["Deploy Subgraph"]
    end

    SGCode --> CI
    CI --> SR
    SR --> Check
    Check -->|"Compatible\nNo breaking changes"| Deploy
    Check -->|"Conflict detected\nor breaking change"| Fail["Build Fails\nDeveloper Notified"]

    style Check fill:#E67E22,color:#fff
    style Deploy fill:#27AE60,color:#fff
    style Fail fill:#E74C3C,color:#fff

Schema Registry #

The schema registry is the component storing all subgraph schemas and performing schema composition — the process of merging all subgraphs into a supergraph and validating that there are no conflicts.

What composition validates:
  ✓ No types redefined in conflicting ways
  ✓ All @key fields exist in the subgraph that owns the type
  ✓ All @external fields actually exist in the subgraph that owns them
  ✓ All @requires fields are available from the right subgraph

Example of a prevented conflict:
  User Subgraph: type User { id: ID!, name: String! }
  Order Subgraph: type User { id: ID!, name: Int! }  ← conflict: different name types
  → Schema composition fails → the subgraph can't be deployed to production

Approach Comparison #

GraphQL Federation vs GraphQL Monolith #

GraphQL Monolith — suited for:
  ✓ Small teams (1-3 teams that can easily coordinate)
  ✓ Unstable domains (schemas still changing significantly)
  ✓ Early-stage products that don't need microservices yet
  ✓ Teams not yet familiar with Federation complexity

GraphQL Federation — suited for:
  ✓ Organizations with 4+ teams having different domains
  ✓ Existing microservice architectures
  ✓ Teams needing independent deploys without coordination
  ✓ Schemas already fairly stable per domain

GraphQL Federation vs REST API Gateway #

REST API Gateway:
  ✓ Conceptually simpler
  ✓ No team needs to understand GraphQL
  ✓ Easier HTTP caching
  ✗ Clients need multiple requests for data from various services
  ✗ No strong type system
  ✗ Over-fetching and under-fetching remain

GraphQL Federation:
  ✓ One query for data from many services
  ✓ A strong type system prevents undetected breaking changes
  ✓ Client-driven data fetching
  ✗ Higher learning curve
  ✗ Higher infrastructure complexity
  ✗ Harder debugging

Federation Anti-Patterns to Avoid #

Overly Granular Subgraphs #

//  Anti-pattern: too many small subgraphs
UserProfileSubgraph      only type UserProfile
UserPreferencesSubgraph  only type UserPreferences
UserAvatarSubgraph       only type UserAvatar
 Every user query needs resolution across 3+ subgraphs
 High network overhead
 No organizational benefit since the same team manages all three

//  Solution: Subgraphs aligned with bounded contexts / domains
UserSubgraph  UserProfile, UserPreferences, UserAvatar, UserAddress
 One subgraph, one team, one cohesive domain

Circular Dependencies Between Subgraphs #

//  Anti-pattern: circular dependencies
// User Subgraph @requires fields from the Order Subgraph
// Order Subgraph @requires fields from the User Subgraph
//  Query plans can't be created, circular dependency

type User @key(fields: "id") @extends {
  // ... in the Order Subgraph
  totalSpent: Float! @requires(fields: "recentOrderCount")  // from the User Subgraph?
}

//  Solution: Denormalize data or use @provides
// If the Order Subgraph needs User data, fetch from the User Subgraph (not vice versa)
// For aggregates, do them on the "needing" side, not the "owned" side

Business Logic in the Router/Gateway #

//  Anti-pattern: the Router as an orchestration layer with business logic
// The Router is not the place for:
//   - Complex data transformations
//   - Business rules
//   - Custom data aggregation
//   - Per-feature rate limiting

//  The Router is only for:
//   - Query planning and routing
//   - Basic authentication checks (token validation)
//   - Rate limiting at the global level
//   - Error normalization
//   - Logging and tracing

No Schema Governance #

// ✗ Anti-pattern: deploying subgraphs without schema checks
The Order Team directly deploys a change removing the `Order.user` field
→ Clients using that field error immediately
→ The User Team doesn't know the field they depend on is gone

// ✓ Solution: Schema checks in the CI pipeline
Every PR changing a subgraph schema must go through:
  1. apollo schema check (compatibility check against the schema registry)
  2. Breaking-change checks against all other subgraphs
  3. Checks for clients using the removed fields
  → PRs can only merge if all checks are green
Breaking changes in Federation are more dangerous than breaking changes in regular GraphQL because their impact can span subgraphs. Removing a @key field in one subgraph can make other subgraphs extending that entity unable to resolve entities at all. Schema registries and automated schema checks in CI are a must, not optional.

Observability in Federation #

Because one client query can result in several subqueries across multiple subgraphs, observability in Federation needs a different approach than regular GraphQL.

What needs monitoring in Federation:

1. Per query (at the Router level):
   → Total latency from the client's perspective
   → The query plan used (how many subgraphs were called)
   → Error rate per operation

2. Per subgraph (at every subgraph):
   → Resolver latency per field
   → Entity resolution latency (for @key queries)
   → Error rate

3. Cross-subgraph:
   → How often subgraph A calls subgraph B
   → Distributed traces from the client query down to all subgraphs

4. Schema usage:
   → Which fields are most used (for schema governance)
   → Which fields are never used (candidates for deprecation)
Apollo Studio provides built-in distributed tracing and field usage analytics for Federation. If you’re not using Apollo Studio, you need to set up OpenTelemetry yourself in the Router and every subgraph to get the same visibility. Without distributed tracing, debugging performance issues in Federation is very difficult.

When to Use Federation vs Not #

Use GraphQL Federation if:
  ✓ There are 4+ teams with different domains needing independent deploys
  ✓ A mature microservice architecture already exists
  ✓ The monolithic schema is already a real bottleneck (not an assumption)
  ✓ Engineering teams are familiar with GraphQL and ready to learn Federation
  ✓ There's a need for cross-team schema governance
  ✓ The organization is large enough to justify the infrastructure overhead

Stay with a GraphQL Monolith if:
  ✓ Small teams (< 4 teams contributing to the schema)
  ✓ Domains are still unstable — many large schema changes ahead
  ✓ The product is still early-stage, no microservice needs yet
  ✓ Teams aren't familiar with GraphQL at all

Use REST + API Gateway (not Federation) if:
  ✓ Teams aren't familiar with GraphQL and lack bandwidth to learn
  ✓ Simple data fetching patterns, no cross-domain join needs
  ✓ HTTP caching is a primary requirement
  ✓ Public APIs consumed by many external parties with diverse tools

GraphQL Federation Checklist #

ARCHITECTURE AND DESIGN:
  □ Subgraphs split by bounded contexts / domains, not database tables
  □ Every subgraph owned by one clear team
  □ No circular dependencies between subgraphs
  □ @key fields use stable identifiers (not ones that can change)

FEDERATION DIRECTIVES:
  □ @key used for all entities other subgraphs might reference
  □ @external only for fields genuinely originating from other subgraphs
  □ @requires used carefully — every @requires adds a subgraph call
  □ @provides used for query plan optimization when data is denormalized

SCHEMA GOVERNANCE:
  □ Schema registry used (Apollo Studio or alternatives)
  □ Schema checks mandatory in the CI pipeline before merging
  □ Breaking changes go through a deprecation period
  □ Field usage metrics available for schema decisions

ROUTER / GATEWAY:
  □ The Router contains no business logic
  □ Auth headers propagated to all subgraphs
  □ Query complexity limiting implemented at the Router level
  □ Global-level rate limiting in place

OBSERVABILITY:
  □ Distributed tracing from the Router to all subgraphs
  □ Per-subgraph latency monitored
  □ Error rate per operation monitored
  □ Query plan logging available for debugging

DEPLOYMENT:
  □ Subgraphs deployable independently
  □ Schema composition runs in CI before deploying
  □ Individual subgraph rollbacks possible without affecting other subgraphs

Summary #

  • Federation splits a GraphQL monolith into subgraphs owned by different teams — every subgraph is deployment-independent while contributing to one unified supergraph for clients.
  • The Router creates query plans automatically — clients don’t need to know how many subgraphs exist. The Router decides which subgraphs to call and in what order.
  • @key is the most important directive — it marks entities referenceable across subgraphs. Every subgraph extending another entity needs the @key from the subgraph that owns it.
  • @requires adds latency — every @requires forces the Router to call subgraphs in a specific (sequential, not parallel) order. Use it sparingly.
  • Subgraphs should align with domain bounded contexts — not database tables or existing REST endpoints. Too granular creates overhead; too large eliminates Federation’s benefits.
  • Schema governance isn’t optional — schema registries and automated schema checks in CI are the minimum requirement. Breaking changes in Federation can damage cross-subgraph behavior unpredictably.
  • The Router is a thin layer — business logic, data transformations, and complex orchestration must not live in the Router. The Router is only for query planning, basic auth checks, and observability.
  • Distributed tracing is mandatory — without cross-subgraph visibility, debugging performance issues in Federation is very difficult. Every subquery must be traceable as part of the larger client query.
  • Federation isn’t for every organization — it’s optimal for 4+ teams with mature domains and existing microservice architectures. For small teams or early-stage products, a GraphQL monolith is simpler and more appropriate.
  • Federation is both an architectural and organizational decision — it changes how teams collaborate, how schemas are governed, and how infrastructure is managed. Adopt it with full awareness of the trade-offs that come with it.
#

← Previous: OAuth   Next: API Security

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact