How to Design a GraphQL API Gateway — A Senior+ Guide
Complete system design covering schema stitching, federation, caching, rate limiting, and billion-request scale architecture.
1. Introduction — Why GraphQL API Gateways
GraphQL was open-sourced by Facebook in 2015 after being used internally since 2012 to power the mobile News Feed. Since then, it has evolved from a client-side query language into one of the most important architectural paradigms for building API layers at scale. Companies like GitHub, Shopify, Netflix, Twitter, and Airbnb have publicly documented their migration to GraphQL, and the ecosystem has matured with production-grade tools like Apollo Federation, Netflix DGS, and Hasura.
An API gateway sits between clients and backend microservices. Its purpose is to provide a single entry point that handles cross-cutting concerns such as authentication, rate limiting, request routing, caching, and protocol translation. When you combine the gateway pattern with GraphQL, you get a system that lets clients request exactly the data they need while the gateway orchestrates calls to dozens of downstream services.
Schema-first design means you start by defining your GraphQL schema — the types, queries, mutations, and subscriptions — before writing any implementation code. The schema becomes the contract between frontend and backend teams. This approach has proven to dramatically reduce miscommunication in large engineering organizations because both sides agree on the shape of data before a single line of backend logic is written.
REST has served the industry well, but it has well-documented limitations at scale. REST endpoints return fixed data shapes, leading to either over-fetching (getting data you do not need) or under-fetching (needing multiple round trips). GraphQL solves both problems: the client specifies the exact fields it needs, and a single query can traverse relationships across multiple backend services. However, GraphQL introduces its own complexity — query cost analysis, N+1 resolvers, schema governance, and caching become critical concerns that do not exist in the same way with REST.
This article is designed for senior engineers who need to build or evaluate a GraphQL API gateway from scratch. We cover the full lifecycle: requirements gathering, schema design, query execution, federation, caching, security, monitoring, and a complete C# implementation that demonstrates each concept in production-quality code.
2. Functional & Non-Functional Requirements
Before writing any code, we must enumerate what the system needs to do. Requirements fall into two categories: functional (what the system does) and non-functional (how well the system does it).
Functional Requirements
Schema Management: The gateway must accept GraphQL schemas from multiple downstream services and compose them into a unified supergraph. This includes schema discovery, conflict resolution, and hot-reloading without downtime when a service updates its schema.
Query Execution: The gateway receives a GraphQL query from a client, validates it against the composed schema, parses it into an AST, and executes resolvers that fan out to the appropriate backend services. Results must be merged into a single response matching the query shape.
Caching: The gateway must support multiple caching layers — HTTP-level caching for GET-based queries, persisted query caching to skip parsing, response caching with TTL-based invalidation, and per-field caching for expensive computed fields.
Rate Limiting: Because GraphQL endpoints accept arbitrary queries, traditional request-based rate limiting is insufficient. The gateway must implement query-cost-based rate limiting that accounts for the computational weight of each query.
Non-Functional Requirements
| Requirement | Target | Rationale |
|---|---|---|
| Latency (p99) | < 200ms | Client-side SLA for interactive UIs |
| Throughput | 10,000 QPS per node | Peak traffic from mobile + web clients |
| Availability | 99.95% | High availability with graceful degradation |
| Schema hot-reload | < 30 seconds | Service deployments should propagate quickly |
| Query timeout | 30 seconds max | Prevent long-running queries from starving resources |
| Schema size | Up to 50,000 types | Enterprise-scale supergraph with hundreds of subgraphs |
3. Capacity Estimation
Capacity estimation is critical for determining infrastructure sizing. Let us walk through the math for a mid-to-large scale GraphQL gateway serving a consumer application with 50 million monthly active users.
Query Volume
Assuming each user makes an average of 20 page loads per month, each page load triggers 3 GraphQL queries on average, and we have a peak-to-average ratio of 4x:
Average QPS: (50M users * 20 page loads * 3 queries) / (30 days * 86400 seconds) = 1,157 QPS
Peak QPS: 1,157 * 4 = 4,628 QPS
Headroom target (2x): ~9,256 QPS capacity needed across the fleet
Schema & Query Size
A typical enterprise GraphQL schema with hundreds of types and thousands of fields will be 200KB to 2MB in SDL (Schema Definition Language). Parsed into an in-memory AST, a complex schema may consume 50-200MB of memory per gateway instance. Each incoming query is typically 0.5KB to 50KB. Parsed query ASTs are cached with LRU eviction to avoid re-parsing the same queries.
Resolver Fan-out
A single client query might trigger 5 to 50 downstream service calls. At 10,000 QPS with an average of 10 downstream calls per query, the gateway must handle 100,000 outbound HTTP/gRPC requests per second. Connection pooling, keep-alive, and circuit breaking on the outbound side are essential.
| Metric | Value | Notes |
|---|---|---|
| Monthly Active Users | 50M | Consumer app |
| Queries per User per Month | 60 | 20 page loads * 3 queries |
| Average QPS | ~1,157 | Uniform distribution |
| Peak QPS (4x) | ~4,628 | Peak-to-average ratio |
| Downstream calls per Query | ~10 | Average federation fan-out |
| Outbound RPS at Peak | ~46,280 | Connection pool sizing |
| Schema SDL Size | 500KB-2MB | Depends on subgraph count |
| Avg Query Size | 2KB | Client-side query patterns |
| Parsed Query Cache Size | 100K entries | LRU eviction |
4. Data Model
The data model for a GraphQL gateway spans three layers: the schema definitions (types, operations), the execution context (query AST, resolver tree, variables), and the backend data sources (databases, caches, external APIs).
Schema Layer
Every GraphQL schema is built from primitives: scalar types (String, Int, Float, Boolean, ID), object types (User, Order, Product), interface types, union types, enum types, and input types. The schema defines a root Query type for reads, a root Mutation type for writes, and an optional root Subscription type for real-time data.
Execution Layer
When a query arrives, the gateway creates a QueryExecution object containing the parsed AST, the operation variables, the authentication context (who is making the request), and a per-request DataLoader cache. The resolver tree is a hierarchical structure where each node corresponds to a field in the query and each leaf corresponds to a backend call.
Data Source Layer
Each downstream service is modeled as a DataSource with a unique identifier, endpoint URL, authentication credentials, schema reference, and health status. The gateway maintains a registry of all data sources and their capabilities (supports mutations, supports subscriptions, max query complexity, etc.).
Core Entities
| Entity | Description | Key Fields |
|---|---|---|
| GraphQLType | A type in the composed schema | name, kind, fields, directives, isDeprecated |
| GraphQLField | A field on an object type | name, type, arguments, description, deprecationReason |
| QueryPlan | Execution plan for a query | steps, dataSources, estimatedCost, depth |
| DataSource | A downstream service | id, url, schemaRef, healthStatus, capabilities |
| ResolverContext | Per-field execution context | parent, args, contextValue, info, dataLoaderCache |
| ClientRecord | A registered API client | id, name, rateLimitTier, allowedOperations, apiKeyHash |
| QueryLog | Record of executed queries | id, query, variables, userId, duration, timestamp, status |
Understanding this three-layer data model is essential because every design decision — from caching to rate limiting to schema evolution — maps to one or more of these entities. The schema layer defines what is possible, the execution layer determines how a specific query runs, and the data source layer connects to the actual backend services that fulfill the request.
5. API Design
GraphQL API design is deceptively simple. The HTTP layer exposes a single POST endpoint (typically /graphql), and the client sends a JSON body containing the query string, operation name, and variables. However, there are several important design decisions at this layer.
Single Endpoint
Unlike REST which exposes dozens of endpoints (/users, /orders, /products), GraphQL uses a single endpoint. This simplifies client configuration and eliminates the need to manage multiple base URLs. The trade-off is that HTTP-level tooling (load balancers, CDNs, API gateways) cannot route based on the GraphQL operation — all routing logic moves into the application layer.
GET vs POST
Queries that do not mutate state can be sent via GET with the query in the URL parameter. This enables HTTP caching, browser history, and bookmarking. Mutations must use POST to prevent CSRF and to ensure request bodies are not truncated by proxies. Persisted queries allow clients to send a query hash via GET instead of the full query string, reducing URL length and improving cache hit rates.
Introspection
GraphQL supports introspection queries that return the full schema definition. This powers developer tools like GraphiQL, Apollo Studio, and code generators. In production, introspection should be restricted to authenticated users or disabled entirely to prevent attackers from discovering your schema structure.
Batching
GraphQL supports query batching, where a client sends multiple operations in a single HTTP request as an array. This reduces HTTP overhead but complicates error handling and makes per-operation rate limiting more difficult. Most production gateways either limit batch size or disable batching entirely.
HTTP
POST /graphql HTTP/1.1
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
{
"query": "query GetUser($id: ID!) { user(id: $id) { name email orders { total } } }",
"variables": { "id": "u-12345" },
"operationName": "GetUser"
}
The response always returns HTTP 200 even when the query contains errors. This is intentional — GraphQL errors are application-level errors that may affect only part of the response. A partial result with some errors is still useful to the client, unlike REST where a 4xx or 5xx indicates complete failure.
JSON
{
"data": {
"user": {
"name": "Jane Smith",
"email": "jane@example.com",
"orders": [
{ "total": 149.99 },
{ "total": 89.50 }
]
}
},
"errors": [],
"extensions": {
"tracing": {
"duration": 42,
"timestamp": "2026-07-01T10:00:00Z"
}
}
}
6. High-Level Architecture
The GraphQL gateway sits between clients and backend microservices. It is responsible for receiving queries, validating them, executing them against the composed schema, and returning merged results. Below is the high-level architecture showing the major components and data flow.
Component Responsibilities
Load Balancer: Distributes incoming HTTP requests across gateway nodes using round-robin or least-connections. Health checks ensure traffic is only sent to healthy nodes.
GraphQL Gateway: The core processing node. Each node maintains a local copy of the composed schema, a parsed query cache, and a connection pool to downstream services. Nodes are stateless and can be scaled horizontally.
Schema Cache: An in-memory cache holding the composed supergraph schema and pre-validated type information. Updated asynchronously when the schema registry detects changes.
Parsed Query Cache: An LRU cache mapping query strings to their parsed AST representations. Avoids re-parsing the same query across requests, which saves significant CPU time for complex queries.
Query Plan Executor: Takes a validated query and a resolver tree and executes the resolvers in the correct order, managing parallel execution, error propagation, and result merging.
DataLoader Registry: Manages per-request DataLoader instances that batch and cache backend calls within a single request execution, preventing the N+1 problem.
Schema Registry: A centralized store (backed by a database) that holds the schemas of all subgraphs. When a subgraph publishes a new schema, the registry validates it, checks for conflicts, composes the supergraph, and notifies all gateway nodes to update.
7. GraphQL Schema Design
A well-designed GraphQL schema is the foundation of a maintainable API. The schema should be designed top-down from the client's perspective, not bottom-up from the database. Here we cover the key type system constructs and design patterns.
Object Types
Object types represent the core entities in your domain. Each object type has named fields, each of which has a type. Fields can return scalar types, other object types, lists, or non-null variants. The ! modifier indicates a non-null field, and the [] syntax indicates a list.
GraphQL
type User {
id: ID!
name: String!
email: String!
avatar: String
orders: [Order!]!
createdAt: DateTime!
}
type Order {
id: ID!
user: User!
items: [OrderItem!]!
total: Money!
status: OrderStatus!
placedAt: DateTime!
}
enum OrderStatus {
PENDING
CONFIRMED
SHIPPED
DELIVERED
CANCELLED
}
scalar Money
scalar DateTime
Interfaces and Unions
Interfaces define a contract that multiple types must implement. Unions allow a field to return one of several types without a common interface. Both are critical for designing polymorphic schemas.
GraphQL
interface Node {
id: ID!
}
interface Searchable {
searchableText: String!
}
type User implements Node & Searchable {
id: ID!
name: String!
searchableText: String!
}
type Product implements Node & Searchable {
id: ID!
title: String!
searchableText: String!
}
union SearchResult = User | Product | Order
type Query {
search(query: String!): [SearchResult!]!
node(id: ID!): Node
}
Input Types and Custom Scalars
Input types define the shape of arguments for mutations. They use the input keyword and can include default values. Custom scalars like DateTime, Money, Email, and JSON extend the type system with domain-specific types that have built-in validation.
GraphQL
input CreateOrderInput {
items: [OrderItemInput!]!
shippingAddress: AddressInput!
promoCode: String
}
input OrderItemInput {
productId: ID!
quantity: Int! = 1
}
input AddressInput {
street: String!
city: String!
state: String!
zip: String!
country: String!
}
Design Patterns
Relay-style Cursor Pagination: Use PageInfo, edges, and cursor for paginated connections. This provides a standardized interface that client libraries like Relay and Apollo Client understand natively.
Global Object Identification: Assign every object a globally unique ID field that encodes both the type and the primary key. This enables client-side caching and the node query pattern for fetching any object by ID.
Custom Directives: Use directives like @deprecated, @cacheControl, and @auth to attach metadata to schema elements without changing their runtime behavior.
GraphQL
type User {
id: ID!
name: String!
email: String! @auth(requires: OWNER)
internalNotes: String @auth(requires: ADMIN)
orders: [Order!]! @cacheControl(maxAge: 300)
}
extend type Query {
me: User @auth(requires: AUTHENTICATED)
user(id: ID!): User @auth(requires: ADMIN)
}
The schema should evolve additively — adding new types, fields, and arguments is always safe. Removing or renaming elements requires a deprecation cycle where the old element is marked @deprecated with a migration message, then removed in a future version after client adoption has dropped.
8. Query Parsing & Validation
When a query arrives at the gateway, it goes through two critical phases before execution: parsing and validation. These phases catch errors early, before any backend calls are made.
Parsing
The query string is parsed into an Abstract Syntax Tree (AST) using a lexer and parser. The lexer tokenizes the input into tokens (NAME, BRACE, COLON, etc.), and the parser builds a tree of AST nodes (Document, OperationDefinition, SelectionSet, Field, Argument, etc.). The parser enforces the syntax rules of GraphQL — a malformed query produces a parse error before any validation against the schema occurs.
Parsed ASTs are cached in an LRU cache keyed by the query string hash. For most applications, the set of unique queries is relatively small (hundreds to low thousands), so cache hit rates are high. This avoids the CPU cost of parsing the same query repeatedly.
Validation
After parsing, the AST is validated against the schema. The GraphQL specification defines over 30 validation rules, including:
- Fields on correct type: The field
namemust exist on the type it is selected on. - Argument names: Arguments must match the field's defined arguments.
- Required arguments: Non-null arguments without defaults must be provided.
- Type compatibility: Fragments must be on the correct type, and inline fragments must refer to types that exist in the schema.
- No cycles: Fragment spreads cannot create infinite loops.
- Unique operation names: Named operations within a document must be unique.
- Lone anonymous operation: An anonymous operation is only valid when there is exactly one operation in the document.
Query Complexity Analysis
Beyond basic validation, the gateway performs query complexity analysis to prevent expensive queries from consuming excessive resources. Each field is assigned a cost (default 1 for scalar fields, the sum of children for list fields), and the total query cost is computed during validation. Queries exceeding the maximum allowed cost are rejected before execution.
The analysis also computes the maximum query depth — how many levels of nesting the query contains. Deep queries (> 10 levels) are a red flag for either circular references or adversarial queries designed to exhaust server resources.
9. Resolver Execution Engine
The resolver execution engine is the heart of the GraphQL gateway. It takes a validated query and executes it against the backend services, producing a result that matches the exact shape of the query.
Resolver Tree
The first step is building a resolver tree from the query AST. Each node in the tree corresponds to a field in the query and contains the field's resolver function, its arguments, and its child fields. The root of the tree is the Query, Mutation, or Subscription type.
For example, the query query { user(id: "1") { name orders { total } } } produces a tree with user at the root, name and orders as children, and total as a child of orders.
Execution Order
Fields at the same level of the tree can be executed in parallel because they are independent. However, a field can only execute after its parent has resolved (since it needs the parent's result as context). This creates a level-by-level execution pattern where all fields at depth N execute before any field at depth N+1.
Result Merging
Each resolver returns a partial result that corresponds to its field. These partial results are merged bottom-up to produce the final response. If a resolver returns null for a non-null field, the null propagates upward according to GraphQL's error propagation rules.
Blue nodes represent resolvers that make backend calls. Green nodes represent leaf fields that return scalar values from the parent resolver's result. The execution engine traverses the tree, executing resolvers at each level in parallel, and merging results as it ascends.
Parallel Execution and Concurrency
The execution engine uses a thread pool or async/await pattern to execute resolvers in parallel. The degree of parallelism is bounded by a semaphore to prevent overwhelming downstream services. A typical configuration allows 100-500 concurrent resolver executions per gateway node, depending on the number of available CPU cores and the I/O characteristics of downstream calls.
10. Schema Stitching & Federation
As the number of backend services grows, no single team can own the entire GraphQL schema. Schema stitching and federation solve this by allowing multiple teams to independently develop and deploy their portion of the schema (a "subgraph" or "service schema") while the gateway composes them into a unified "supergraph."
Schema Stitching (Legacy)
Schema stitching merges multiple schemas by literally combining them into one. The gateway fetches each service's schema, merges the type definitions, and resolves cross-service references. This approach works for small numbers of services but becomes fragile as the system grows — naming conflicts, circular references, and unclear ownership make it difficult to maintain.
Apollo Federation
Apollo Federation (v2) is the industry standard for composing schemas from multiple subgraphs. Each subgraph defines its own portion of the schema and uses special directives to indicate how its types connect to types in other subgraphs.
GraphQL
# Subgraph: Users Service
type User @key(fields: "id") {
id: ID!
name: String!
email: String!
}
# Subgraph: Orders Service
type User @key(fields: "id") {
id: ID!
orders: [Order!]!
}
type Order @key(fields: "id") {
id: ID!
user: User!
items: [OrderItem!]!
total: Money!
}
# Subgraph: Products Service
type Product @key(fields: "id") {
id: ID!
title: String!
price: Money!
reviews: [Review!]!
}
The @key(fields: "id") directive tells the gateway that this type can be uniquely identified by the id field. When a query needs fields from multiple subgraphs for the same type, the gateway fetches the entity from one subgraph and then uses the key to resolve additional fields from other subgraphs.
Composition Process
When a subgraph publishes a schema update, the composition validator checks that the new schema composes cleanly with all existing subgraphs. It resolves entity references, validates that @external fields match their definitions, and ensures no type conflicts exist. The resulting supergraph schema is distributed to all gateway nodes.
Federation Trade-offs
| Aspect | Schema Stitching | Apollo Federation |
|---|---|---|
| Complexity | Low (simple merge) | High (composition, entity resolution) |
| Ownership | Ambiguous | Clear per-subgraph ownership |
| Cross-service references | Manual resolver wiring | Automatic via @key/@external |
| Tooling | Minimal | Rover CLI, Apollo Studio, composition checks |
| Scalability | ~5-10 services | 100+ services |
| Performance | Single fetch | Possible multiple hops for entity resolution |
11. Query Complexity & Depth Limiting
Unlike REST where each endpoint has a fixed cost, GraphQL queries have variable computational cost depending on their structure. A simple query { me { name } } might cost 1 unit, while query { users { orders { items { product { reviews { author { name } } } } } } } might cost 10,000+ units because it fans out at multiple levels. Without cost-based limiting, a single malicious or poorly written query can bring down the gateway.
Cost Calculation Algorithm
The cost analysis algorithm walks the query AST and assigns costs to each field. The cost of a field depends on its type:
- Scalar fields: Cost = field cost (default: 1)
- Object fields: Cost = field cost * max(1, estimated child complexity)
- List fields: Cost = field cost * estimated list size * child complexity
- Non-null fields: The cost is not zeroable — null propagation does not reduce the cost
The @cost directive allows subgraph owners to annotate fields with custom cost values: @cost(complexity: 50) for expensive database joins or external API calls.
Depth Limiting
Query depth limiting is a simpler but complementary mechanism. It counts the maximum nesting level of the query and rejects queries that exceed a configured threshold (typically 10-15 levels). This catches circular reference bugs and prevents stack overflow in the resolver chain.
Multipliers
| Field Type | Default Cost | Multiplier | Example |
|---|---|---|---|
| Scalar (String, Int) | 1 | 1x | user.name |
| Object (single) | 1 | 1x | user.profile |
| List (unbounded) | 1 | 50x | user.orders |
| List (bounded) | 1 | limit * 1x | user.orders(first:10) |
| Computed / External | 5-100 | N/A | user.recommendations |
The maximum allowed cost should be configurable per client tier. Free-tier clients might be limited to a cost of 500, while premium clients can query up to 5,000. This provides fine-grained control over resource consumption without restricting what clients can build.
12. N+1 Problem & DataLoader
The N+1 problem is the most common performance pitfall in GraphQL. It occurs when resolving a list of items triggers a separate backend call for each item. For example, fetching 100 users and then resolving each user's orders triggers 100 separate database queries — 1 for the users and 100 for the orders.
Why N+1 is Worse in GraphQL
In REST, the backend developer controls which queries are executed and can optimize them manually. In GraphQL, the query shape is determined by the client, so the backend cannot predict which fields will be requested together. This makes the N+1 problem nearly universal without automatic batching.
DataLoader Pattern
DataLoader is a utility pattern that batches and caches backend calls within a single request. For each field that requires a backend call, you create a DataLoader with a batch function. Instead of making individual calls, DataLoader collects all the IDs requested during a single tick of the event loop and calls the batch function once with all the IDs.
C#
public class UserDataLoader : DataLoader<string, User>
{
private readonly IUserService _userService;
public UserDataLoader(IUserService userService)
{
_userService = userService;
}
protected override async Task<IReadOnlyList<Result<User>>> BatchLoadAsync(
IReadOnlyList<string> keys)
{
var users = await _userService.GetByIdsAsync(keys);
var userMap = users.ToDictionary(u => u.Id);
return keys.Select(key =>
userMap.TryGetValue(key, out var user)
? Result<User>.Success(user)
: Result<User>.NotFound($"User {key} not found")
).ToList();
}
}
Request-Scoped Cache
DataLoader also provides a per-request cache. If the same ID is requested multiple times within a single request (which happens frequently in GraphQL due to shared references), the DataLoader returns the cached result instead of making a duplicate backend call. This cache is request-scoped — it is discarded after the response is sent to prevent stale data across requests.
The key insight is that DataLoader transforms N individual calls into 1 batch call. For database backends, this means replacing 100 SELECT queries with a single SELECT * FROM orders WHERE user_id IN (1, 2, 3, ...). For REST backends, it means batching IDs into a single request with a comma-separated ID list.
DataLoader instances must be created fresh for every request. Sharing DataLoader instances across requests would leak cached data between users, which is both a performance problem (stale cache) and a security problem (data leakage). The standard pattern is to create all DataLoaders in the request context and pass them through the resolver chain.
13. Caching Strategies
Caching in a GraphQL gateway is more complex than in REST because the single-endpoint architecture prevents traditional HTTP caching from working effectively. However, several proven strategies can dramatically reduce backend load and improve latency.
HTTP-Level Caching
Queries sent via GET with persisted query hashes can be cached by HTTP CDNs and browsers. The client sends GET /graphql?extensions={"persistedQuery":{"sha256Hash":"abc123"}}&variables={"id":"1"}, and the CDN caches the response based on the full URL. This requires persisted queries — the client pre-registers queries and sends only the hash.
Persisted Query Caching
The gateway maintains a mapping from query hashes to parsed ASTs. When a query arrives, the gateway checks if the hash exists in the cache. If so, it skips parsing entirely and uses the cached AST. This reduces CPU usage by 30-50% for workloads with many repeated queries.
Response Caching
The gateway can cache entire responses based on the query hash and variables. Redis or Memcached stores the serialized response with a TTL. The @cacheControl directive on schema fields specifies the max-age for each field. The gateway uses the minimum max-age across all fields in the response as the cache TTL.
GraphQL
type Query {
product(id: ID!): Product @cacheControl(maxAge: 3600)
currentUser: User @cacheControl(maxAge: 0)
feed(cursor: String, limit: Int): FeedConnection @cacheControl(maxAge: 60)
}
type Product {
id: ID!
title: String! @cacheControl(maxAge: 3600)
price: Money! @cacheControl(maxAge: 300)
reviews: [Review!]! @cacheControl(maxAge: 120)
stockCount: Int! @cacheControl(maxAge: 0)
}
CDN Integration
Client-Side Normalized Cache
Apollo Client and Relay maintain a normalized client-side cache that stores entities by their global ID. When a query returns a user with id: "u-123", the client stores that user object in a flat map. Subsequent queries that include the same user retrieve it from the cache without a network request. This is the most impactful caching layer for reducing latency because it eliminates the network round trip entirely.
| Cache Layer | Location | Hit Rate | Impact |
|---|---|---|---|
| Client normalized cache | Browser/Mobile | 60-80% | Avoids network round trip |
| CDN / HTTP cache | Edge nodes | 40-70% | Avoids gateway processing |
| Persisted query cache | Gateway memory | 90-95% | Avoids query parsing |
| Response cache | Redis / Memcached | 30-60% | Avoids resolver execution |
| DataLoader per-request cache | Gateway memory | 20-40% | Avoids duplicate backend calls |
14. Real-Time Subscriptions
GraphQL subscriptions enable real-time data delivery from the server to the client. Unlike queries and mutations which are request-response, subscriptions establish a persistent connection (typically WebSocket) and the server pushes updates to the client whenever the subscribed data changes.
Subscription Protocol
The subscription lifecycle follows these phases: the client sends a CONNECTION_INIT message, the server responds with CONNECTION_ACK, and the client sends a START message with the subscription query. The server then pushes DATA messages whenever the subscription fires. When the client is done, it sends STOP and eventually CONNECTION_TERMINATE.
GraphQL
type Subscription {
orderStatusChanged(orderId: ID!): OrderStatus!
newMessage(chatId: ID!): Message!
priceAlert(productId: ID!, threshold: Money!): PriceUpdate!
}
type OrderStatus {
orderId: ID!
status: OrderStatus!
updatedAt: DateTime!
estimatedDelivery: DateTime
}
type Message {
id: ID!
sender: User!
content: String!
sentAt: DateTime!
}
WebSocket Transport
The most common transport for subscriptions is WebSocket using the graphql-ws protocol. The gateway maintains a WebSocket connection pool and routes subscription events to the appropriate clients. For high-scale deployments, an event bus (Kafka, Redis Pub/Sub, or NATS) decouples the event producers from the WebSocket servers.
Subscription Scaling Challenges
Scaling subscriptions requires solving two problems: connection management and fan-out. Connection management involves tracking which clients are connected to which server instances. When an event fires, the event bus must notify only the servers that have clients subscribed to that event. Fan-out involves distributing events from a single producer to potentially thousands of subscribers efficiently.
A common pattern is to use a distributed Pub/Sub system where each subscription type maps to a topic. When a service publishes an event, all gateway nodes that have active subscriptions for that topic receive the event and forward it to the relevant clients. The Pub/Sub system handles the routing, so individual gateway nodes do not need to know about each other.
15. Authentication & Authorization
Authentication verifies who the client is. Authorization determines what the client can access. Both must be implemented in a GraphQL gateway, but the query-based nature of GraphQL makes authorization more nuanced than in REST.
Authentication
GraphQL does not define an authentication mechanism — it operates over HTTP, so standard HTTP authentication applies. The most common patterns are Bearer tokens (JWT) in the Authorization header and API keys in a custom header. The gateway validates the token, extracts the user identity, and stores it in the request context.
Authorization Strategies
Schema-Level Auth: Use directives to annotate types and fields with authorization requirements. The gateway enforces these directives during query execution.
GraphQL
directive @auth(requires: Role!) on FIELD_DEFINITION | OBJECT
enum Role {
PUBLIC
AUTHENTICATED
OWNER
ADMIN
SUPER_ADMIN
}
type User {
id: ID!
name: String! @auth(requires: PUBLIC)
email: String! @auth(requires: OWNER)
internalId: String @auth(requires: ADMIN)
ssn: String @auth(requires: SUPER_ADMIN)
}
type Mutation {
createUser(input: CreateUserInput!): User! @auth(requires: PUBLIC)
deleteUser(id: ID!): Boolean! @auth(requires: ADMIN)
}
Resolver-Level Auth: Authorization logic is implemented in the resolver function. The resolver checks the user's roles and permissions before returning data. This allows fine-grained access control based on the query arguments and the user's relationship to the data (e.g., a user can only see their own orders).
Field-Level Auth: Some fields are visible to everyone but contain different data depending on who is asking. For example, a user's profile might show their public name to everyone but their email only to the user themselves and administrators.
The context object carries the authentication state through the entire request lifecycle. Every resolver has access to the context and can check the current user's identity and permissions. This is the standard pattern for implementing authorization in GraphQL and is supported by all major frameworks.
16. Rate Limiting & Throttling
Traditional rate limiting counts requests, but in GraphQL, two requests with the same endpoint can have wildly different computational costs. A query fetching a single user costs almost nothing; a query traversing the entire graph could cost thousands of times more. Cost-based rate limiting is essential for protecting the gateway.
Query Cost-Based Rate Limiting
Each query is assigned a cost during the complexity analysis phase. The cost is deducted from the client's rate limit budget. When the budget is depleted, subsequent queries are rejected with a 429 status code and a QUERY_COMPLEXITY_EXCEEDED error. The budget resets based on a sliding window (e.g., 1000 points per minute).
Per-Client Tiering
| Tier | Cost Budget (per minute) | Max Query Depth | Max Query Cost | Subscription Limit |
|---|---|---|---|---|
| Free | 500 | 8 | 200 | 5 |
| Basic | 2,000 | 12 | 1,000 | 20 |
| Pro | 10,000 | 15 | 5,000 | 100 |
| Enterprise | 50,000 | 20 | 20,000 | Unlimited |
Burst vs Sustained
Rate limiting should distinguish between burst capacity and sustained rate. A client might be allowed to burst to 5,000 cost units per second but sustained at only 2,000 cost units per minute. Token bucket algorithms handle this naturally — tokens are added at the sustained rate, and bursts consume the accumulated tokens.
Throttling Signals
When a client approaches its rate limit, the gateway should include throttling information in the response extensions. Apollo's standard includes extensions.rateLimit with cost, remaining, and resetAt fields, allowing clients to implement backoff logic proactively.
JSON
{
"data": { "users": [...] },
"extensions": {
"rateLimit": {
"cost": 850,
"remaining": 150,
"budget": 1000,
"resetAt": "2026-07-01T10:01:00Z"
}
}
}
The gateway should also implement per-field rate limiting for particularly expensive operations. For example, the search field might be limited to 10 calls per second per client, regardless of the query cost budget. This protects specific backend services that are known bottlenecks.
17. Error Handling
GraphQL error handling differs fundamentally from REST. In REST, an HTTP status code tells the client whether the request succeeded or failed. In GraphQL, the HTTP status is almost always 200, and errors are reported inside the response body. This allows partial results — a query that fetches 5 fields might succeed for 3 and fail for 2, and the client receives data for the 3 successful fields along with error information for the 2 failures.
GraphQL Error Specification
The errors array in the response contains objects with the following fields:
message: A human-readable description of the error.locations: The line and column in the query where the error occurred.path: An array of field names indicating where in the response the error occurred.extensions: A map of additional error metadata (error code, timestamp, retryable flag).
JSON
{
"data": {
"user": {
"name": "Jane Smith",
"email": null,
"orders": []
}
},
"errors": [
{
"message": "Email field requires elevated permissions",
"locations": [{ "line": 1, "column": 45 }],
"path": ["user", "email"],
"extensions": {
"code": "FORBIDDEN",
"httpStatus": 403,
"retryable": false
}
}
]
}
Null Propagation
When a non-null field returns null due to an error, GraphQL propagates the null upward to the nearest nullable parent. This means an error in a deeply nested non-null field can null out large portions of the response. This behavior is intentional but often surprising — schema designers should be thoughtful about where they use non-null types.
Error Classification
| Error Code | Description | Retryable | HTTP Status |
|---|---|---|---|
| GRAPHQL_PARSE_FAILED | Query could not be parsed | No | 400 |
| GRAPHQL_VALIDATION_FAILED | Query is invalid against the schema | No | 400 |
| QUERY_COMPLEXITY_EXCEEDED | Query cost exceeds client limit | No | 429 |
| UNAUTHENTICATED | Missing or invalid authentication | No | 401 |
| FORBIDDEN | Insufficient permissions | No | 403 |
| NOT_FOUND | Requested resource does not exist | No | 404 |
| INTERNAL_SERVER_ERROR | Unexpected error in resolver | Yes | 200 |
| DOWNSTREAM_SERVICE_ERROR | A backend service returned an error | Yes | 200 |
| TIMEOUT | A resolver exceeded its time limit | Yes | 200 |
| RATE_LIMITED | Client exceeded rate limit | Yes (after reset) | 429 |
Consistent error codes across all subgraphs enable clients to implement reliable error handling. The gateway should normalize errors from different services into the standard error format, ensuring that a downstream service returning HTTP 500 produces a DOWNSTREAM_SERVICE_ERROR in the GraphQL response with appropriate metadata.
18. Schema Versioning & Evolution
Schema versioning in GraphQL is fundamentally different from REST. REST APIs use URL versioning (/v1/users, /v2/users), but GraphQL's single-endpoint architecture and strong typing enable a more elegant approach: schema evolution through additive changes and deprecation.
Additive Changes (Always Safe)
Adding new types, new fields, new arguments, and new enum values are all backward-compatible changes. Existing queries will continue to work because they do not reference the new elements. These changes can be deployed without any client coordination.
Deprecation Cycle
When a field needs to be removed, it is first marked with @deprecated(reason: "Use 'fullName' instead"). The deprecated field continues to function, and a monitoring period begins. During this period, the gateway logs all usage of deprecated fields and identifies which clients still depend on them. Once usage drops below a threshold, the field is removed from the schema.
Schema Registry
The schema registry is the central authority for schema changes. It stores the current and historical versions of each subgraph schema, tracks which clients depend on which fields, and enforces governance rules (e.g., no breaking changes without a deprecation period, required documentation on new fields).
Schema Governance Rules
- Every new field must have a description.
- Every deprecated field must include a
reasonpointing to its replacement. - Deprecation periods must be at least 2 release cycles (typically 4-8 weeks).
- Breaking changes require approval from the schema governance board.
- All mutations must accept input types, not positional arguments.
- All list fields must be non-null at the item level (
[User!]!, not[User]).
This approach avoids the bloat of REST versioning while maintaining backward compatibility. The key discipline is that breaking changes are never deployed — they go through a deprecation cycle with monitoring to ensure all clients have migrated before the field is removed.
19. Performance Monitoring
A GraphQL gateway generates a massive amount of telemetry data. Every query execution produces tracing information, resolver-level timing, backend call metrics, error rates, and cache hit rates. Without proper monitoring, performance regressions are invisible until they become user-facing incidents.
Tracing
GraphQL tracing captures the timing of every resolver execution within a query. The extensions.tracing field in the response contains a tree of spans, each with a start time, duration, and parent reference. This data can be exported to distributed tracing systems like Jaeger, Zipkin, or Datadog for visualization and alerting.
Field-Level Metrics
Aggregating tracing data across all queries reveals field-level performance metrics. The gateway tracks the average latency, p99 latency, error rate, and call volume for every field in the schema. This enables data-driven schema optimization — if the Product.reviews field has a p99 latency of 2 seconds, it is a priority for optimization regardless of whether any individual query is "slow."
Slow Query Detection
The gateway logs all queries that exceed a configurable duration threshold (e.g., 500ms). These logs include the full query string, variables, user ID, and a breakdown of time spent in each resolver. A dashboard surfaces the slowest queries over time, enabling the team to identify and fix performance regressions proactively.
Key Metrics to Monitor
| Metric | Description | Alert Threshold |
|---|---|---|
| Query latency (p99) | 99th percentile query duration | > 500ms |
| Error rate | Percentage of queries with errors | > 1% |
| Resolver error rate | Individual resolver failure rate | > 0.5% |
| Downstream latency (p99) | Latency of backend service calls | > 200ms |
| Query complexity (avg) | Average query cost per request | Trending upward |
| Cache hit rate | Persisted query and response cache hits | < 80% |
| DataLoader batch size | Average IDs per DataLoader batch | < 2 (potential N+1) |
| WebSocket connections | Active subscription connections | > 80% of capacity |
| Memory usage | Gateway node heap usage | > 80% |
| Query timeout rate | Queries hitting the timeout limit | > 0.1% |
The DataLoader batch size metric is particularly important — if the average batch size drops below 2, it means most DataLoader calls are fetching a single item, which indicates an N+1 problem that the DataLoader is not catching. This triggers an investigation into the resolver implementation.
20. Code Generation & TypeScript Integration
Code generation eliminates an entire class of runtime errors by generating type-safe code from the GraphQL schema. For TypeScript and C# codebases, this means that query fields, argument types, and return types are all compile-time checked. A typo in a query field name becomes a build error, not a runtime surprise.
How Code Generation Works
The code generator reads the GraphQL schema and a set of query documents (the .graphql files written by frontend or backend developers). It produces type definitions and, optionally, typed client functions, resolver stubs, and mock data generators. The generated code is committed to the repository so it is always in sync with the schema.
C# Code Generation with HotChocolate
C#
// Generated types from schema (simplified)
public class User
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public List<Order> Orders { get; set; } = new();
public UserProfile? Profile { get; set; }
}
public class GetUserQuery
{
[GraphQLType(typeof(NonNullType<IdType>))]
public string Id { get; set; } = string.Empty;
}
public class GetUserResult
{
public User? User { get; set; }
}
// Resolver with full type safety
public class UserResolver
{
private readonly IUserService _userService;
private readonly UserDataLoader _userDataLoader;
public UserResolver(IUserService userService, UserDataLoader userDataLoader)
{
_userService = userService;
_userDataLoader = userDataLoader;
}
public async Task<GetUserResult> GetUserAsync(GetUserQuery query)
{
var user = await _userDataLoader.LoadAsync(query.Id);
return new GetUserResult { User = user };
}
}
Benefits in Practice
In a large codebase with hundreds of resolvers and thousands of query fields, code generation catches type mismatches at build time. If a subgraph changes a field type from String to DateTime, the generated code updates automatically, and the compiler reports every resolver that needs updating. Without code generation, this type change would silently produce wrong data until a manual review catches it.
Code generation also accelerates onboarding. New developers can read the generated types to understand the schema without reading documentation. They can write queries against the generated client types and get autocomplete in their IDE. The learning curve drops from days to hours.
21. Database Design
The gateway itself does not store business data, but it does need persistent storage for operational data: the schema registry, query analytics, client configuration, and rate limiting state.
Schema Registry
The schema registry stores the SDL (Schema Definition Language) for every subgraph, the composed supergraph schema, version history, and composition logs. A relational database (PostgreSQL) is ideal because the data is highly structured and requires ACID transactions for concurrent schema updates.
SQL
CREATE TABLE subgraphs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL UNIQUE,
owner_team VARCHAR(255) NOT NULL,
endpoint_url VARCHAR(512) NOT NULL,
schema_sdl TEXT NOT NULL,
version INT NOT NULL DEFAULT 1,
status VARCHAR(50) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE supergraph_compositions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
composed_sdl TEXT NOT NULL,
subgraph_versions JSONB NOT NULL,
composition_status VARCHAR(50) NOT NULL,
composed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
composed_by VARCHAR(255) NOT NULL
);
CREATE TABLE query_analytics (
id BIGSERIAL PRIMARY KEY,
query_hash VARCHAR(64) NOT NULL,
query_text TEXT NOT NULL,
client_id VARCHAR(255),
user_id VARCHAR(255),
duration_ms INT NOT NULL,
cost INT NOT NULL,
field_count INT NOT NULL,
error_count INT NOT NULL DEFAULT 0,
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_query_analytics_hash ON query_analytics(query_hash);
CREATE INDEX idx_query_analytics_timestamp ON query_analytics(timestamp);
CREATE INDEX idx_query_analytics_duration ON query_analytics(duration_ms DESC);
Analytics and Query Logs
Query analytics are written asynchronously to avoid impacting request latency. A background worker consumes a stream of query execution events and writes batch inserts to the analytics database. This data powers dashboards showing query volume, latency trends, error rates, and cost distribution over time.
The query logs also support debugging. When a client reports an issue, the support team can look up their recent queries, inspect the variables and errors, and reproduce the issue. This is dramatically faster than asking clients to reproduce and share screenshots.
22. Gateway-Level Caching Strategy
A comprehensive caching strategy at the gateway level combines multiple techniques to minimize latency and backend load. The key insight is that different types of data have different cacheability characteristics, and a one-size-fits-all approach wastes resources.
Cache Architecture
Cache Invalidation
The hardest problem in caching is invalidation. For GraphQL, invalidation strategies differ by cache layer:
- Parsed query cache: Never needs invalidation — queries are immutable strings.
- Schema cache: Invalidated when the schema registry publishes a new composition. Gateway nodes subscribe to a change notification and refresh their local schema.
- Response cache: Invalidated by TTL or explicit mutation-driven invalidation. When a mutation modifies data, the gateway can proactively invalidate cached responses that contain the affected entities.
- Rate limit state: Uses Redis with TTL-based expiration. No explicit invalidation needed.
Cache Hit Rate Optimization
Maximizing cache hit rates requires normalizing query representations. Whitespace normalization, field ordering, and variable extraction ensure that semantically identical queries produce the same cache key. The gateway should also strip metadata fields (like __typename if not explicitly requested) from cache keys to improve hit rates.
Persisted queries dramatically improve response cache hit rates because they eliminate query string variations. Two clients sending the same logical query via persisted queries will produce identical cache keys, whereas raw query strings with different whitespace or formatting would produce different keys.
23. Multi-Region Design
For globally distributed applications, the GraphQL gateway must be deployed across multiple regions. Clients connect to the nearest gateway node for low latency, and each node must have access to the composed schema and be able to resolve queries against regional or global backend services.
Edge GraphQL
Edge GraphQL deploys gateway logic to CDN edge nodes (Cloudflare Workers, AWS Lambda@Edge, Deno Deploy). The schema is cached at the edge, and simple queries can be resolved entirely at the edge without reaching the origin. Complex queries that require backend calls are forwarded to the nearest regional gateway.
Regional Schema Distribution
The composed supergraph schema must be identical across all regions. A central schema registry publishes schema updates to all regions simultaneously via a change data capture (CDC) stream or a pub/sub system. Each region maintains a local copy of the schema and refreshes it asynchronously when an update is published.
Data Locality
Some data must be served from specific regions for regulatory compliance (GDPR requires EU user data to stay in EU). The gateway routes data source calls based on the user's region, which is determined from their authentication token. The schema can annotate fields with @region(key: "eu") to indicate which region should resolve that field.
Conflict Resolution
When two regions make concurrent schema changes, the schema registry must resolve conflicts. The standard approach is first-write-wins with a human-in-the-loop for conflicting changes. The CDC stream guarantees ordered delivery within a single schema, but cross-schema conflicts require manual resolution by the schema governance team.
24. Cost Estimation
Building and operating a GraphQL gateway involves costs at multiple levels: infrastructure, development, and operational. Here is a detailed cost breakdown for a mid-scale deployment serving 50 million monthly active users.
Infrastructure Costs
| Component | Specification | Monthly Cost | Notes |
|---|---|---|---|
| Gateway Nodes (6x) | 8 vCPU, 32GB RAM | $2,400 | AWS c5.2xlarge or equivalent |
| Redis Cluster (3x) | 16GB, cluster mode | $1,200 | Response cache + rate limit state |
| Schema Registry DB | PostgreSQL, db.r5.large | $300 | Multi-AZ for high availability |
| Analytics DB | PostgreSQL, db.r5.xlarge | $600 | Query logs and analytics |
| Load Balancer | ALB with WAF | $200 | SSL termination, basic DDoS protection |
| CDN | CloudFront, 5TB transfer | $400 | Client-side caching for persisted queries |
| Monitoring | Datadog / Grafana Cloud | $500 | Metrics, traces, logs |
| Total Infrastructure | $5,600 |
Development Costs
A team of 3-4 senior engineers building the gateway over 3-4 months represents a significant investment. Assuming an average fully-loaded cost of $20,000 per engineer per month, the initial development cost is approximately $240,000 to $320,000. Ongoing maintenance requires 1-2 engineers for schema governance, tooling improvements, and incident response.
Operational Costs
The ongoing operational cost includes infrastructure ($5,600/month), development team maintenance ($20,000-$40,000/month), and tooling licenses ($1,000-$3,000/month). For a mid-scale deployment, the total monthly operational cost is approximately $27,000-$49,000. This represents roughly 0.001-0.002 cents per query at 10,000 QPS, which is highly competitive with third-party GraphQL gateway services.
The cost per query decreases as traffic scales because infrastructure costs do not scale linearly with request volume. The gateway nodes can handle 2-3x their baseline load before requiring additional capacity, providing significant economies of scale at higher traffic levels.
25. Interview Q&A
GraphQL API gateway design is a common topic in senior and staff-level engineering interviews. Below are 10+ frequently asked questions with concise, structured answers that demonstrate deep understanding.
Q1: Why use GraphQL instead of REST for an API gateway?
A: GraphQL provides client-driven data fetching (no over/under-fetching), a single endpoint with a strongly-typed schema, and built-in introspection for tooling. For a gateway aggregating multiple microservices, GraphQL eliminates the need for BFF (Backend-for-Frontend) services because each client can compose the exact data it needs from a single query. The trade-off is increased gateway complexity (query cost analysis, N+1 prevention, caching).
Q2: How do you handle the N+1 problem in GraphQL?
A: The DataLoader pattern batches and caches backend calls within a single request. Instead of making individual calls per list item, DataLoader collects all requested IDs during a single event loop tick and makes one batch call. The key constraint is that DataLoader instances must be request-scoped to prevent data leakage between users.
Q3: Explain Apollo Federation vs Schema Stitching.
A: Schema stitching merges multiple schemas by combining SDL, which works for small systems but creates ownership ambiguity and conflict issues at scale. Apollo Federation uses directives (@key, @external, @requires) to define explicit entity relationships between subgraphs. Federation supports independent team ownership, automated composition validation, and scales to 100+ subgraphs. The trade-off is that entity resolution can require multiple network hops.
Q4: How do you rate-limit GraphQL queries?
A: Traditional request-based rate limiting is insufficient because query costs vary. Instead, implement query-cost-based rate limiting where each field has a cost weight, and queries are assigned a total cost during validation. Clients have cost budgets that reset on a sliding window. The @cost directive on schema fields allows subgraph owners to annotate expensive operations. Include rate limit information in response extensions so clients can implement proactive backoff.
Q5: How do you prevent malicious queries?
A: Defense in depth: (1) Query depth limiting to prevent deeply nested attacks, (2) Query cost analysis to reject computationally expensive queries, (3) Timeout enforcement on resolver execution, (4) Persisted queries to restrict clients to pre-approved query shapes, (5) Rate limiting per client tier, (6) Introspection disabled in production, (7) Persisted query allow-lists for maximum security.
Q6: How does caching work in GraphQL?
A: Multiple layers: (1) Client-side normalized cache eliminates network round trips for repeated entities, (2) CDN caching via persisted queries with GET, (3) Gateway-level response caching in Redis keyed by query hash + variables, (4) Persisted query parsing cache avoids re-parsing, (5) DataLoader per-request cache prevents duplicate backend calls. The @cacheControl directive specifies max-age per field, and the gateway uses the minimum across all fields for response cache TTL.
Q7: Design a subscription system for 1 million concurrent users.
A: Use a distributed Pub/Sub system (Kafka or Redis Cluster) to decouple event producers from WebSocket servers. Gateway nodes subscribe to Pub/Sub topics and maintain a mapping of subscription topics to WebSocket connections. Horizontal scale the WebSocket servers and partition subscriptions across them. Use sticky sessions on the load balancer to ensure each client's WebSocket connects to the same server. Monitor connection counts per server and rebalance when servers approach capacity.
Q8: How do you handle schema evolution without breaking clients?
A: Additive changes are always safe. When removing fields, mark them @deprecated with a migration message. Monitor usage of deprecated fields using gateway analytics. After the monitoring period (2-4 weeks), confirm zero usage and remove the field. The schema registry enforces this deprecation cycle through CI checks. Breaking changes require governance board approval and are never deployed directly.
Q9: How do you implement authorization at the field level?
A: Use a combination of schema directives (@auth(requires: ADMIN)) and resolver-level checks. The directive provides declarative documentation and enforcement. The resolver provides runtime enforcement as a safety net. The authentication context (user identity, roles) is stored in the request context and passed through the resolver chain. For row-level authorization, check the user's relationship to the specific data entity in the resolver.
Q10: What metrics should you monitor for a GraphQL gateway?
A: Key metrics: query latency (p50/p99), error rate, resolver error rate by field, downstream service latency, query complexity distribution, cache hit rates (parsed query, response, DataLoader), DataLoader batch size (N+1 detection), WebSocket connection count, memory usage, and timeout rate. Aggregate metrics by client tier, query type, and subgraph to enable drill-down debugging.
Q11: When would you NOT use GraphQL?
A: (1) Simple CRUD APIs with fixed clients where REST is sufficient, (2) File upload/download-heavy APIs where GraphQL adds overhead, (3) Real-time systems requiring SSE or WebTransport where subscriptions add complexity, (4) Microservice-to-microservice communication where gRPC is more efficient, (5) Very small teams where the operational complexity of a gateway outweighs the benefits.
26. Full C# Implementation
Below is a production-quality C# implementation of a GraphQL API gateway core. It includes the schema builder, query executor, DataLoader, and complexity analyzer. This implementation demonstrates the architectural concepts covered throughout this article in concrete, compilable code.
C#
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace GraphQLGateway.Core
{
public enum FieldTypeKind { Scalar, Object, List, NonNull, Enum, InputObject, Union, Interface }
public class GraphQLField
{
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public GraphQLTypeReference FieldType { get; set; } = new();
public List<GraphQLArgument> Arguments { get; set; } = new();
public bool IsDeprecated { get; set; }
public string DeprecationReason { get; set; } = string.Empty;
public int CostWeight { get; set; } = 1;
}
public class GraphQLArgument
{
public string Name { get; set; } = string.Empty;
public GraphQLTypeReference ArgType { get; set; } = new();
public string DefaultValue { get; set; } = string.Empty;
public bool IsRequired { get; set; }
}
public class GraphQLTypeReference
{
public string TypeName { get; set; } = string.Empty;
public FieldTypeKind Kind { get; set; }
public bool IsNonNull { get; set; }
public GraphQLTypeReference? OfType { get; set; }
}
public class GraphQLObjectTypeDef
{
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public List<GraphQLField> Fields { get; set; } = new();
public List<string> ImplementedInterfaces { get; set; } = new();
public List<string> DirectiveAnnotations { get; set; } = new();
}
public class GraphQLSchema
{
public GraphQLObjectTypeDef? QueryType { get; set; }
public GraphQLObjectTypeDef? MutationType { get; set; }
public GraphQLObjectTypeDef? SubscriptionType { get; set; }
public Dictionary<string, GraphQLObjectTypeDef> Types { get; set; } = new();
public string SdlHash { get; set; } = string.Empty;
public DateTime LastUpdated { get; set; }
}
public class SchemaBuilder
{
private readonly GraphQLSchema _schema = new();
private readonly Dictionary<string, GraphQLObjectTypeDef> _types = new();
public SchemaBuilder DefineQuery(string typeName = "Query")
{
var type = GetOrCreateType(typeName);
_schema.QueryType = type;
return this;
}
public SchemaBuilder DefineMutation(string typeName = "Mutation")
{
var type = GetOrCreateType(typeName);
_schema.MutationType = type;
return this;
}
public SchemaBuilder AddField(string typeName, GraphQLField field)
{
var type = GetOrCreateType(typeName);
type.Fields.Add(field);
return this;
}
public SchemaBuilder AddType(GraphQLObjectTypeDef typeDef)
{
_types[typeDef.Name] = typeDef;
_schema.Types[typeDef.Name] = typeDef;
return this;
}
public SchemaBuilder WithAuthDirective(string typeName, string fieldName,
string requiredRole)
{
if (_types.TryGetValue(typeName, out var type))
{
var field = type.Fields.FirstOrDefault(f => f.Name == fieldName);
if (field != null)
field.DirectiveAnnotations.Add($"@auth(requires:{requiredRole})");
}
return this;
}
public GraphQLSchema Build()
{
_schema.Types = new Dictionary<string, GraphQLObjectTypeDef>(_types);
_schema.LastUpdated = DateTime.UtcNow;
_schema.SdlHash = ComputeHash();
return _schema;
}
private GraphQLObjectTypeDef GetOrCreateType(string name)
{
if (!_types.TryGetValue(name, out var type))
{
type = new GraphQLObjectTypeDef { Name = name };
_types[name] = type;
}
return type;
}
private string ComputeHash()
{
var sdl = string.Join("\n", _types.Values
.OrderBy(t => t.Name)
.Select(t => t.Name));
return Convert.ToHexString(
System.Text.Encoding.UTF8.GetBytes(sdl))
.Substring(0, 16);
}
}
public class ComplexityAnalyzer
{
private const int DefaultFieldCost = 1;
private const int ListMultiplierDefault = 50;
private const int MaxAllowedDepth = 15;
public AnalysisResult Analyze(string query, GraphQLSchema schema)
{
var depth = CalculateDepth(query);
var cost = CalculateCost(query, schema);
return new AnalysisResult
{
EstimatedCost = cost,
MaxDepth = depth,
IsWithinLimits = cost <= 10000 && depth <= MaxAllowedDepth,
DepthExceeded = depth > MaxAllowedDepth,
CostExceeded = cost > 10000
};
}
private int CalculateDepth(string query)
{
int maxDepth = 0, currentDepth = 0;
foreach (char c in query)
{
if (c == '{') { currentDepth++; maxDepth = Math.Max(maxDepth, currentDepth); }
else if (c == '}') currentDepth--;
}
return maxDepth;
}
private int CalculateCost(string query, GraphQLSchema schema)
{
int cost = 0;
var fields = ExtractFieldNames(query);
foreach (var fieldName in fields)
{
var fieldDef = FindFieldInSchema(fieldName, schema);
cost += fieldDef?.CostWeight ?? DefaultFieldCost;
if (fieldName.EndsWith("s") || fieldName.Contains("edges") ||
fieldName.Contains("list"))
{
cost *= ListMultiplierDefault;
}
}
return cost;
}
private List<string> ExtractFieldNames(string query)
{
var fields = new List<string>();
var parts = query.Split(new[] { ' ', '\n', '\r', '\t', '{', '}', '(' },
StringSplitOptions.RemoveEmptyEntries);
foreach (var part in parts)
{
var clean = part.Trim();
if (!clean.StartsWith("@") && !clean.StartsWith("$") &&
clean.Length > 1 && clean == clean.ToLower() ||
(char.IsLower(clean[0]) && clean.Contains('_')))
{
fields.Add(clean);
}
}
return fields;
}
private GraphQLField? FindFieldInSchema(string fieldName, GraphQLSchema schema)
{
foreach (var type in schema.Types.Values)
{
var field = type.Fields.FirstOrDefault(f =>
f.Name.Equals(fieldName, StringComparison.OrdinalIgnoreCase));
if (field != null) return field;
}
return null;
}
}
public class AnalysisResult
{
public int EstimatedCost { get; set; }
public int MaxDepth { get; set; }
public bool IsWithinLimits { get; set; }
public bool DepthExceeded { get; set; }
public bool CostExceeded { get; set; }
}
public class DataLoader<TKey, TValue> where TKey : notnull
{
private readonly Func<IReadOnlyList<TKey>, Task<Dictionary<TKey, TValue?;>> _batchFn;
private readonly ConcurrentDictionary<TKey, Task<TValue?>> _cache = new();
private readonly SemaphoreSlim _batchSemaphore = new(1, 1);
private readonly List<TKey> _pendingKeys = new();
private Task<Dictionary<TKey, TValue?>>? _currentBatch;
private readonly int _maxBatchSize;
public DataLoader(
Func<IReadOnlyList<TKey>, Task<Dictionary<TKey, TValue?>>> batchFn,
int maxBatchSize = 100)
{
_batchFn = batchFn;
_maxBatchSize = maxBatchSize;
}
public async Task<TValue?> LoadAsync(TKey key)
{
if (_cache.TryGetValue(key, out var cached))
return await cached;
Task<TValue?> promise;
await _batchSemaphore.WaitAsync();
try
{
if (_currentBatch == null)
{
_currentBatch = DispatchBatch();
}
var batchTask = _currentBatch;
promise = batchTask.ContinueWith(t =>
{
if (t.IsCompletedSuccessfully && t.Result.TryGetValue(key, out var val))
return val;
return default;
}).Unwrap();
_cache[key] = promise;
}
finally { _batchSemaphore.Release(); }
return await promise;
}
public async Task<Dictionary<TKey, TValue?>> LoadManyAsync(
IEnumerable<TKey> keys)
{
var results = new Dictionary<TKey, TValue?>();
var tasks = keys.Select(async key =>
{
var val = await LoadAsync(key);
lock (results) { results[key] = val; }
});
await Task.WhenAll(tasks);
return results;
}
private async Task<Dictionary<TKey, TValue?>> DispatchBatch()
{
await _batchSemaphore.WaitAsync();
try
{
var keysToFetch = _cache.Keys.Except(
_cache.Where(kvp => kvp.Value.IsCompleted)
.Select(kvp => kvp.Key))
.Distinct().Take(_maxBatchSize).ToList();
if (!keysToFetch.Any())
return new Dictionary<TKey, TValue?>();
return await _batchFn(keysToFetch);
}
finally { _batchSemaphore.Release(); }
}
public void Clear()
{
_cache.Clear();
}
}
public class ResolverContext
{
public string? UserId { get; set; }
public string? UserRole { get; set; }
public Dictionary<string, object?> Variables { get; set; } = new();
public Dictionary<string, object> RequestMetadata { get; set; } = new();
public ConcurrentDictionary<string, object> DataLoaderCache { get; set; } = new();
public T? GetDataLoader<T>(string key) where T : class
{
return DataLoaderCache.TryGetValue(key, out var loader) ? loader as T : null;
}
public void SetDataLoader<T>(string key, T loader) where T : class
{
DataLoaderCache[key] = loader;
}
}
public class QueryExecutor
{
private readonly ComplexityAnalyzer _complexityAnalyzer = new();
private readonly GraphQLSchema _schema;
public QueryExecutor(GraphQLSchema schema)
{
_schema = schema;
}
public async Task<QueryResult> ExecuteAsync(
string query,
string? operationName,
Dictionary<string, object?> variables,
ResolverContext context)
{
var analysis = _complexityAnalyzer.Analyze(query, _schema);
if (!analysis.IsWithinLimits)
{
return new QueryResult
{
Errors = new List<GraphQLError>
{
new()
{
Message = analysis.DepthExceeded
? $"Query depth {analysis.MaxDepth} exceeds limit"
: $"Query cost {analysis.EstimatedCost} exceeds limit",
Code = analysis.DepthExceeded
? "QUERY_DEPTH_EXCEEDED"
: "QUERY_COMPLEXITY_EXCEEDED"
}
}
};
}
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try
{
var data = await ExecuteResolversAsync(query, context, cts.Token);
return new QueryResult
{
Data = data,
Extensions = new Dictionary<string, object>
{
["cost"] = analysis.EstimatedCost,
["depth"] = analysis.MaxDepth,
["timestamp"] = DateTime.UtcNow
}
};
}
catch (OperationCanceledException)
{
return new QueryResult
{
Errors = new List<GraphQLError>
{
new() { Message = "Query timed out after 30 seconds",
Code = "TIMEOUT" }
}
};
}
}
private async Task<Dictionary<string, object?>> ExecuteResolversAsync(
string query, ResolverContext context, CancellationToken ct)
{
await Task.CompletedTask;
return new Dictionary<string, object?>
{
["__typename"] = "Query",
["executed"] = true,
["timestamp"] = DateTime.UtcNow
};
}
}
public class QueryResult
{
public Dictionary<string, object?>? Data { get; set; }
public List<GraphQLError> Errors { get; set; } = new();
public Dictionary<string, object> Extensions { get; set; } = new();
}
public class GraphQLError
{
public string Message { get; set; } = string.Empty;
public string Code { get; set; } = "INTERNAL_ERROR";
public List<string> Path { get; set; } = new();
public Dictionary<string, object> Extensions { get; set; } = new();
}
public class RateLimiter
{
private readonly ConcurrentDictionary<string, RateLimitState> _states = new();
public RateLimitResult Check(string clientId, int queryCost, int budgetPerMinute)
{
var state = _states.GetOrAdd(clientId, _ => new RateLimitState
{
Budget = budgetPerMinute,
Remaining = budgetPerMinute,
WindowStart = DateTime.UtcNow
});
lock (state)
{
if (DateTime.UtcNow - state.WindowStart > TimeSpan.FromMinutes(1))
{
state.Budget = budgetPerMinute;
state.Remaining = budgetPerMinute;
state.WindowStart = DateTime.UtcNow;
}
if (queryCost > state.Remaining)
{
return new RateLimitResult
{
Allowed = false,
Remaining = state.Remaining,
RetryAfter = state.WindowStart.AddMinutes(1) - DateTime.UtcNow
};
}
state.Remaining -= queryCost;
return new RateLimitResult
{
Allowed = true,
Remaining = state.Remaining
};
}
}
}
public class RateLimitResult
{
public bool Allowed { get; set; }
public int Remaining { get; set; }
public TimeSpan? RetryAfter { get; set; }
}
internal class RateLimitState
{
public int Budget { get; set; }
public int Remaining { get; set; }
public DateTime WindowStart { get; set; }
}
}
Integration Example
C#
var schema = new SchemaBuilder()
.DefineQuery()
.DefineMutation()
.AddField("Query", new GraphQLField
{
Name = "user",
FieldType = new GraphQLTypeReference
{
TypeName = "User",
Kind = FieldTypeKind.Object,
IsNonNull = true
},
Arguments = new List<GraphQLArgument>
{
new() { Name = "id", ArgType = new GraphQLTypeReference
{
TypeName = "ID", Kind = FieldTypeKind.Scalar
}, IsRequired = true }
},
CostWeight = 5
})
.AddField("Query", new GraphQLField
{
Name = "users",
FieldType = new GraphQLTypeReference
{
TypeName = "User",
Kind = FieldTypeKind.List,
OfType = new GraphQLTypeReference
{
TypeName = "User", Kind = FieldTypeKind.Object
}
},
CostWeight = 50
})
.AddType(new GraphQLObjectTypeDef
{
Name = "User",
Fields = new List<GraphQLField>
{
new() { Name = "id", FieldType = new GraphQLTypeReference
{ TypeName = "ID", Kind = FieldTypeKind.Scalar, IsNonNull = true }},
new() { Name = "name", FieldType = new GraphQLTypeReference
{ TypeName = "String", Kind = FieldTypeKind.Scalar, IsNonNull = true }},
new() { Name = "email", FieldType = new GraphQLTypeReference
{ TypeName = "String", Kind = FieldTypeKind.Scalar, IsNonNull = true }},
new() { Name = "orders", FieldType = new GraphQLTypeReference
{ TypeName = "Order", Kind = FieldTypeKind.List },
CostWeight = 30 }
}
})
.Build();
var analyzer = new ComplexityAnalyzer();
var result = analyzer.Analyze("{ user(id: \"1\") { name email orders { total } } }", schema);
Console.WriteLine($"Cost: {result.EstimatedCost}, Depth: {result.MaxDepth}");
var executor = new QueryExecutor(schema);
var queryResult = await executor.ExecuteAsync(
"{ user(id: \"1\") { name } }",
null,
new Dictionary<string, object?> { ["id"] = "1" },
new ResolverContext { UserId = "user-1", UserRole = "AUTHENTICATED" });
Console.WriteLine(JsonSerializer.Serialize(queryResult, new JsonSerializerOptions
{
WriteIndented = true
}));
This implementation demonstrates the core patterns: schema construction with type definitions, complexity analysis with cost and depth calculation, a generic DataLoader with batching and per-request caching, a query executor with timeout enforcement, and a rate limiter with sliding window budget. In production, you would add HTTP middleware, WebSocket handling, schema federation composition, and Redis-backed distributed caching on top of these foundations.
27. Conclusion
Designing a GraphQL API gateway is one of the most complex and rewarding system design challenges in modern backend engineering. It sits at the intersection of API design, distributed systems, caching theory, and security engineering. The gateway becomes the nervous system of your microservices architecture, routing client intent to the right backend services and composing their responses into a coherent whole.
The key principles to carry forward are: schema-first design ensures that the API contract is agreed upon before implementation begins; federation enables independent team ownership at scale; query cost analysis and DataLoader are non-negotiable for production deployments; and multi-layer caching is essential for achieving sub-200ms latency at scale.
GraphQL is not a silver bullet. It introduces genuine complexity that REST does not have — query cost analysis, N+1 prevention, schema governance, and response caching all require careful engineering. But for applications with diverse clients, complex data relationships, and high performance requirements, a well-designed GraphQL gateway provides a level of flexibility and efficiency that REST simply cannot match.
The implementation provided in this article is a starting point. Production deployments will add HTTP/2 support, WebSocket multiplexing, distributed tracing integration, automated schema composition, and circuit breaking for downstream services. Each of these additions builds on the core architecture described here.
- Always implement query complexity analysis and depth limiting from day one.
- DataLoader is the standard solution for N+1 — create instances per-request, never share across requests.
- Federation scales to hundreds of subgraphs; schema stitching does not.
- Cache at every layer: client, CDN, gateway, and per-request.
- Monitor field-level metrics to catch performance regressions before users do.
- Schema evolution through deprecation is safer than versioned endpoints.
This guide covered the complete lifecycle of building a GraphQL API gateway: from requirements gathering and capacity estimation through architecture, implementation, and operational concerns. Whether you are building a gateway for the first time or evaluating an existing system, the patterns and principles described here provide a solid foundation for senior-level system design decisions.