GraphQL Gateway Low-Level Design: Schema Stitching, DataLoader Batching, and Persisted Queries

Federation and Schema Ownership

A GraphQL gateway unifies multiple microservice schemas into a single graph that clients query. Each service owns its slice of the schema and its resolvers. The gateway stitches them at runtime.

Apollo Federation uses the @key directive to declare entity ownership:

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

# In Order service — extends User with order data
extend type User @key(fields: "id") {
  id: ID! @external
  orders: [Order!]!
}

The gateway merges these schemas. A query for user { id name orders { total } } is split into a User service query and an Order service query, results joined by user ID. Schema stitching (non-federation) achieves the same result with explicit type merging config but requires more manual maintenance when schemas change.

The N+1 Problem and DataLoader Batching

N+1 is the canonical GraphQL performance failure. Querying 10 posts where each post resolver fetches its author separately produces 11 queries: one for posts, ten for authors.

DataLoader solves this with batching within a single event loop tick:

const userLoader = new DataLoader(async (userIds) => {
  // Called once with [id1, id2, ..., id10]
  const users = await db.query(
    'SELECT * FROM users WHERE id = ANY($1)',
    [userIds]
  );
  // Return in same order as input ids
  return userIds.map(id => users.find(u => u.id === id));
});

// Each post resolver calls:
const author = await userLoader.load(post.author_id);
// DataLoader collects all calls in one tick, fires one batch query

DataLoader instances are per-request — create them in the GraphQL context factory, not as singletons. Sharing loaders across requests causes cross-request data leakage.

Query Complexity and Depth Limits

Without limits, a single GraphQL query can request an exponentially large response. Two controls prevent this:

  • Depth limit: Maximum nesting depth of the query AST. A depth of 10 prevents recursive self-referential queries from creating arbitrarily deep traversals. Return a validation error at parse time, before execution begins.
  • Complexity limit: Each field is assigned a cost. Nested lists multiply cost (a list of 10 items containing a list of 10 sub-items = 100 units). Total query cost must not exceed the configured threshold. Introspection queries are expensive — count them as a fixed high cost or disable introspection in production.
const complexityRule = createComplexityRule({
  maximumComplexity: 1000,
  variables: {},
  onComplete: (complexity) => console.log('Query complexity:', complexity),
  estimators: [
    fieldExtensionsEstimator(),
    simpleEstimator({ defaultComplexity: 1 }),
  ],
});

Persisted Queries

Arbitrary query execution is a security risk: attackers can craft maximally expensive queries and hammer the gateway. Persisted queries fix this: clients register queries at deploy time, the server stores the mapping of query hash → query document. At runtime, clients send only the hash:

POST /graphql
{ "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "abc123..." } } }

If the hash is unknown, the server returns an error asking the client to send the full query for registration. Benefits: prevents arbitrary query injection, reduces request payload size (hash vs full query string), enables CDN caching of GET requests with hash parameters.

Field-Level Authorization

Route-level auth (validate JWT at the gateway) is insufficient for GraphQL — different users may have access to different fields on the same type. Implement authorization in resolvers:

const resolvers = {
  User: {
    email: (parent, args, context) => {
      if (!context.user.roles.includes('admin') &&
          context.user.id !== parent.id) {
        return null; // or throw AuthorizationError
      }
      return parent.email;
    }
  }
};

Schema directives (@auth(requires: ADMIN)) centralize authorization logic and keep resolvers clean. The directive checks permissions before the resolver runs, returning null or an error for unauthorized fields.

Response Caching

GraphQL responses are cacheable at the field level using @cacheControl directives. The gateway computes the minimum maxAge across all fields in the response and sets Cache-Control accordingly:

type Post @cacheControl(maxAge: 300) {
  id: ID!
  title: String!
  author: User @cacheControl(maxAge: 60)
}

Public data (product listings, blog posts) can be cached at CDN level when served via GET requests with persisted query hashes. Private data (user profiles, orders) must have scope: PRIVATE and is not CDN-cacheable.

Subscription Support

GraphQL subscriptions deliver real-time updates over WebSocket. The gateway maintains a persistent WebSocket connection per client. When a mutation triggers an event, the gateway publishes to a pub/sub channel (Redis pub/sub or in-memory EventEmitter). Subscribers receive the update via their WebSocket connection. In a horizontally scaled gateway, pub/sub must be external (Redis) so all gateway nodes receive events and can deliver to their locally connected clients.

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “How does Apollo Federation stitch schemas from multiple services?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Apollo Federation uses a gateway that fetches each subgraph's SDL and merges them into a supergraph schema using entity references — types annotated with @key directives that let the gateway join data across service boundaries. At query time the gateway's query planner decomposes the operation into a fetch plan, making parallel requests to subgraphs and merging results before returning a single response to the client.”
}
},
{
“@type”: “Question”,
“name”: “How does DataLoader prevent N+1 queries in GraphQL?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “DataLoader batches all individual load calls that occur within a single event loop tick into a single batch fetch, then caches results for the lifetime of the request so repeated loads for the same key are served instantly. This converts O(n) sequential database round trips into a single batched query, eliminating the N+1 problem common in naive resolver implementations.”
}
},
{
“@type”: “Question”,
“name”: “How are query complexity limits enforced to prevent expensive queries?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “The gateway performs static analysis on the parsed query AST before execution, assigning a cost to each field based on its type multiplier and depth, then rejecting any query whose accumulated score exceeds a configured threshold. Depth limits and field count limits are applied as complementary guards to prevent deeply nested or excessively wide queries from reaching resolvers.”
}
},
{
“@type”: “Question”,
“name”: “What are persisted queries and why are they used?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Persisted queries store a pre-approved query document on the server keyed by its hash, allowing clients to send only the hash rather than the full query string, which reduces request payload size and enables a whitelist security model that rejects arbitrary ad-hoc queries. This also improves CDN cacheability since GET requests carrying only a stable hash can be cached at the edge.”
}
}
]
}

See also: Meta Interview Guide 2026: Facebook, Instagram, WhatsApp Engineering

See also: Netflix Interview Guide 2026: Streaming Architecture, Recommendation Systems, and Engineering Excellence

See also: Atlassian Interview Guide

Scroll to Top