Siddhant Deval
Siddhant Deval
backend22 min read

Federation & Subgraph Architecture: The Domain-Owned Graph

Federation is not a GraphQL scaling trick — it is the application of domain-driven design to the schema layer. A subgraph boundary is wrong if it maps to a service, and correct only if it maps to a domain that a single team owns end-to-end.

Federation & Subgraph Architecture: The Domain-Owned Graph

You own the schema and the resolvers — the schema is a public contract you can never silently break, and every resolver is a performance commitment you make on every query. When you introduce Apollo Federation, you add a third commitment: the subgraph boundary is a team boundary, and drawing it incorrectly creates the same coupling problems as a monolith, except now across a network. Federation earns its complexity only when each subgraph is owned end-to-end by one team — from the type User @key(fields: "id") declaration to the PostgreSQL schema behind it. When teams split subgraphs by technical layer instead of domain, Federation delivers all the operational cost of a distributed system with none of the organizational benefit.

Architectural Note

This is Part 3 of the GraphQL Backend & API Design series. It builds on Part 1 (Schema Design: The SDL as a Domain Contract) and Part 2 (Resolvers, DataLoader: The N+1 Is Always Your Fault). Federation concepts require you to understand @key, which is GraphQL's distributed foreign key — Part 1's discussion of ID! as a semantic type is the prerequisite.


1. When Federation Earns Its Complexity

Federation is not a default architecture. It is a solution to a specific organizational problem: multiple teams needing to contribute to a single graph without coordinating on every schema change.

1.1 The Wrong Reason to Use Federation

GRAPHQL
# ❌ Team split by technical layer — not by domain
# This is the most common Federation anti-pattern

# Frontend team's subgraph:
type Query {
  getUser(id: ID!): UserView    # Read model
  getUserFeed: [FeedItem!]!
}

# Backend team's subgraph:
type Mutation {
  createUser(input: CreateUserInput!): UserMutationResult!
  updateUser(id: ID!, input: UpdateUserInput!): UserMutationResult!
}

# Problem: both teams still need to coordinate on the User type definition,
# @key field selection, and any cross-type references. You have distributed
# system overhead with none of the domain independence.

1.2 The Right Boundary: Bounded Context → Subgraph

A correct subgraph boundary maps exactly to a domain that one team owns — including the database, the business logic, and the operational runbook:

GRAPHQL
# ✅ Users subgraph — owned entirely by the Identity team
# They own: users table, auth service, user profile logic
type User @key(fields: "id") {
  id: ID!
  name: String!
  email: String!
  createdAt: DateTime!
}

type Query {
  user(id: ID!): User
  viewer: User
}

type Mutation {
  createUser(input: CreateUserInput!): CreateUserPayload!
  updateProfile(input: UpdateProfileInput!): UpdateProfilePayload!
}
GRAPHQL
# ✅ Orders subgraph — owned entirely by the Commerce team
# They own: orders table, payment processing, fulfillment logic
type Order @key(fields: "id") {
  id: ID!
  total: Money!
  status: OrderStatus!
  items: [OrderItem!]!
  user: User!   # Reference to User entity — resolved via @key, not a DB join
}

# The Orders subgraph extends the User type to add orders traversal
# WITHOUT the Identity team's involvement
extend type User @key(fields: "id") {
  id: ID! @external
  orders(first: Int, after: String): OrderConnection!
}
Mental Model Check

A subgraph boundary is correct when a team can make any schema change within their subgraph — adding fields, changing non-null, deprecating — without opening a PR against another team's codebase. If your "domain boundary" requires a meeting with another team for every change, it is not a domain boundary.


2. @key — The Distributed Foreign Key

@key is the single most important directive in Federation. It declares which field(s) uniquely identify an entity across subgraphs, enabling the Router to resolve cross-subgraph entity references.

2.1 How Entity Resolution Works

When the Router receives a query that spans subgraphs, it decomposes it into a query plan:

GRAPHQL
# Client sends this query to the Router:
query GetUserOrders {
  user(id: "u1") {
    name        # ← resolved by Users subgraph
    orders {    # ← resolved by Orders subgraph
      total
      status
    }
  }
}

The Router's query plan:

  1. Fetch { user(id: "u1") { name __typename id } } from Users subgraph
  2. Use the returned id: "u1" as the @key to fetch { _entities(representations: [{ __typename: "User", id: "u1" }]) { ... on User { orders { total status } } } } from Orders subgraph
  3. Assemble and return the merged response
TYPESCRIPT
// ✅ The Orders subgraph resolver for the User entity reference
const resolvers = {
  User: {
    // This is the entity reference resolver — called with { id: "u1" }
    // when the Router performs an entity lookup for User.orders
    __resolveReference: async (reference: { id: string }, ctx: GraphQLContext) => {
      // Minimal — the Orders subgraph only needs to resolve orders for this user
      // It does NOT need the full User object from the Users subgraph
      return { id: reference.id };
    },
    orders: async (user: { id: string }, args, ctx) => {
      return ctx.ordersLoader.load(user.id);
    },
  },
};

2.2 Federation 2.5 Key Directives Reference

Directive Purpose Example
@key(fields: "id") Declares entity identifier type User @key(fields: "id")
@external Marks field as defined in another subgraph id: ID! @external
@shareable Allows a non-entity type to be resolved by multiple subgraphs type Money @shareable
@override Migrates a field's resolver from one subgraph to another orders: [...] @override(from: "legacy-orders")
@interfaceObject Extends a federated interface across subgraphs (Fed 2.5) type Node @interfaceObject @key(fields: "id")
@requiresScopes Requires OAuth scopes to access a field adminOrders: [...] @requiresScopes(scopes: [["admin"]])

3. Apollo Router vs. Apollo Gateway

Apollo Gateway (Node.js) entered maintenance mode. Apollo Router (Rust) is the current production gateway and handles the query plan execution for Federation 2.x.

TYPESCRIPT
// ❌ Apollo Gateway — Node.js, maintenance mode, do not use for new projects
import { ApolloGateway } from '@apollo/gateway';
import { ApolloServer } from '@apollo/server';

const gateway = new ApolloGateway({
  supergraphSdl: /* ... */,
});
const server = new ApolloServer({ gateway });
YAML
# ✅ Apollo Router — Rust, production-ready, supports all Federation 2.x features
# router.yaml
supergraph:
  path: /graphql
  listen: 0.0.0.0:4000

plugins:
  # Native OpenTelemetry — zero Node.js plugin required
  telemetry:
    tracing:
      otlp:
        endpoint: http://jaeger:4317

Why Router over Gateway:

  • Performance: Router handles query planning in Rust — significantly lower p99 latency under high concurrency than the Node.js Gateway
  • Native SSE subscriptions (Router v1.40+): Built-in Server-Sent Events subscription transport without a separate subscription service
  • Native OTel: Zero-config OpenTelemetry span emission per subgraph fetch, per field, per entity lookup
  • Persisted Operations: Built-in manifest mode (v1.30+) — clients send operation IDs, not full operation documents

4. Schema Registry CI with rover

rover is the Apollo CLI for schema management. It is the CI gate between a schema change and a subgraph deployment.

4.1 The CI Pipeline

BASH
# In every subgraph's CI pipeline, before deployment:

# 1. Validate the schema change does not break any registered client operations
rover subgraph check my-graph@production \
  --name orders \
  --schema ./schema.graphql

# 2. Publish the subgraph schema to the registry (runs after deployment)
rover subgraph publish my-graph@production \
  --name orders \
  --schema ./schema.graphql \
  --routing-url https://orders.internal/graphql

# 3. Lint the subgraph schema for Federation best practices
rover subgraph lint ./schema.graphql \
  --profile production
Crucial Requirement

rover subgraph check runs in under 10 seconds (v0.20+). If your CI pipeline skips this gate "to save time," you have removed the only mechanism that catches breaking changes to client operations before they reach production. Make it a required check.

4.2 Composition Errors vs. Runtime Errors

Federation composition happens at the schema registry level — when you publish a subgraph schema, Apollo's composition validates that the supergraph remains valid. This catches errors at publish time, not at runtime:

TEXT
✅ Composition-time catch (caught before Router receives traffic):
  - @key field missing from entity type
  - @external field referenced without @override or @provides
  - Type conflict between subgraphs (field type mismatch)

❌ Runtime error (NOT caught by composition):
  - __resolveReference returning null for a valid entity key
  - DataLoader returning keys in the wrong order
  - Subgraph returning a partial entity that causes null propagation

5. Partial Data Semantics When a Subgraph Is Down

Federation changes the error handling contract significantly. When a subgraph is unavailable, the Router does not fail the entire request — it returns partial data.

JSON
// Orders subgraph is down — the Router returns:
{
  "data": {
    "user": {
      "name": "Alice",
      "orders": nullnull, not an empty array — the resolver failed
    }
  },
  "errors": [
    {
      "message": "Failed to fetch from subgraph 'orders'",
      "path": ["user", "orders"],
      "extensions": {
        "code": "DOWNSTREAM_SERVICE_ERROR",
        "serviceName": "orders"
      }
    }
  ]
}

This means the client MUST handle partial data in Federation-backed GraphQL:

TYPESCRIPT
// ✅ Client-side partial data handling (Apollo Client)
const { data, errors } = useQuery(GET_USER_ORDERS);

// data.user.name may be present even when data.user.orders is null
// Check each field independently, not just the top-level data object
const ordersError = errors?.find(e => e.path?.includes('orders'));
if (ordersError) {
  return <OrdersUnavailableState message="Orders service is temporarily unavailable." />;
}
Performance / Safety Warning

Designing your Federation schema with aggressive non-null (orders: [Order!]! instead of orders: [Order!]) on fields resolved by separate subgraphs is dangerous. When the Orders subgraph is down, null propagates upward through the non-null chain and may null out the entire user field — even though the Users subgraph responded correctly. See Part 1's discussion of non-null semantics.


Federated supergraph query decomposition: client query → Router query plan → parallel subgraph fetches → entity stitching → assembled response
Federated supergraph query decomposition: client query → Router query plan → parallel subgraph fetches → entity stitching → assembled response

The Router decomposes one client query into parallel subgraph fetches, uses @key entity references to stitch results across domain boundaries, and returns a single assembled response — even when a subgraph is unavailable (partial data path shown in red).

Three-subgraph topology: Identity, Commerce, and Catalog teams — with @key entity references and bounded context ownership annotations
Three-subgraph topology: Identity, Commerce, and Catalog teams — with @key entity references and bounded context ownership annotations

Each subgraph is owned end-to-end by one team: their type definitions, their database, and their operational runbook. Cross-team references use @key entity lookups — not shared database tables or internal API calls.


Summary

Concept Rule
Subgraph = team domain A subgraph boundary is correct only when one team owns it end-to-end: the SDL, the resolvers, the database schema, and the on-call runbook.
@key is a distributed FK @key declares which field uniquely identifies an entity across subgraphs — the Router uses it to perform cross-subgraph entity resolution without requiring inter-subgraph API calls.
Router over Gateway Apollo Gateway (Node.js) is in maintenance mode. Apollo Router (Rust) is the production gateway for Federation 2.x — native OTel, native SSE subscriptions, lower p99 latency.
Composition CI gate rover subgraph check catches breaking schema changes before deployment — it runs in under 10 seconds and must be a required CI gate, not an optional step.
Partial data is the contract When a subgraph is unavailable, the Router returns partial data — clients must handle null on individual fields independently, not just check the top-level data object.

What's Next

Part 4 — Subscriptions on the Server extends the resolver contract to streaming: subscribe and resolve are two separate functions, and the PubSub broker decision (EventEmitter in development, Redis or Kafka in production) determines whether your subscriptions survive a rolling deployment or silently drop messages.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#GraphQL#Apollo Federation#Apollo Router#Microservices#System Design
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.