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
See also: Atlassian Interview Guide