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

Source: https://www.techinterview.org/post/3233469508/lld-graphql-gateway/
Updated: 2026-04-17 · techinterview.org

## 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.

See also: [Meta Interview Guide 2026: Facebook, Instagram, WhatsApp Engineering](https://www.techinterview.org/post/3233460272/meta-interview-guide-2026-facebook-instagram-whatsapp-engineering/)

See also: [Netflix Interview Guide 2026: Streaming Architecture, Recommendation Systems, and Engineering Excellence](https://www.techinterview.org/post/3233460274/netflix-interview-guide-2026-streaming-architecture-recommendation-systems-and-engineering-excellence/)

See also: [Atlassian Interview Guide](https://www.techinterview.org/post/3233459919/atlassian-interview/)
