GraphQL Architecture
Design production GraphQL APIs that scale. Covers schema design, resolvers, N+1 problem, DataLoader, subscriptions, federation, caching, and the patterns that make GraphQL fast and maintainable.
GraphQL lets clients request exactly the data they need — no more, no less. Unlike REST where the server determines the response shape, GraphQL clients specify exactly which fields they want. This eliminates over-fetching and under-fetching, but introduces new complexity in backend design.
GraphQL vs REST
REST:
GET /api/users/123 → Full user object (30 fields)
GET /api/users/123/orders → All orders (separate request)
GET /api/orders/456 → Full order object (20 fields)
Problems:
- Over-fetching: 30 fields returned, client needs 3
- Under-fetching: Need 3 requests for related data
- Multiple round trips for related data
GraphQL:
query {
user(id: "123") {
name
email
orders(last: 5) {
id
total
status
}
}
}
Single request, exactly the data needed, one round trip
Schema Design
# Schema-first design (SDL)
type Query {
user(id: ID!): User
orders(filter: OrderFilter, first: Int, after: String): OrderConnection!
}
type Mutation {
createOrder(input: CreateOrderInput!): CreateOrderPayload!
cancelOrder(id: ID!): CancelOrderPayload!
}
type User {
id: ID!
name: String!
email: String!
orders(first: Int, after: String): OrderConnection!
createdAt: DateTime!
}
type Order {
id: ID!
total: Money!
status: OrderStatus!
items: [OrderItem!]!
customer: User!
createdAt: DateTime!
}
# Pagination: Relay cursor-based
type OrderConnection {
edges: [OrderEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type OrderEdge {
cursor: String!
node: Order!
}
# Input types for mutations
input CreateOrderInput {
items: [OrderItemInput!]!
shippingAddress: AddressInput!
}
# Mutation payloads with errors
type CreateOrderPayload {
order: Order
errors: [UserError!]!
}
N+1 Problem & DataLoader
# WITHOUT DataLoader (N+1 problem):
# Query: { orders { customer { name } } }
# 1 query to get 100 orders
# 100 queries to get each customer (N+1!)
# WITH DataLoader:
from promise import Promise
from promise.dataloader import DataLoader
class CustomerLoader(DataLoader):
def batch_load_fn(self, customer_ids):
# Single query for ALL customers
customers = Customer.objects.filter(id__in=customer_ids)
customer_map = {c.id: c for c in customers}
return Promise.resolve([
customer_map.get(cid) for cid in customer_ids
])
# Resolver uses DataLoader
def resolve_customer(order, info):
return info.context.customer_loader.load(order.customer_id)
# Result: 1 query for orders + 1 batched query for customers = 2 total
Caching
# Response caching with cache-control directives
type Query {
# Public data, cache for 1 hour
products: [Product!]! @cacheControl(maxAge: 3600, scope: PUBLIC)
# Per-user data, cache for 5 minutes
me: User! @cacheControl(maxAge: 300, scope: PRIVATE)
# Real-time data, no cache
orderStatus(id: ID!): OrderStatus! @cacheControl(maxAge: 0)
}
# Persisted queries (prevent arbitrary queries in production)
# Client sends hash, not full query
# POST /graphql { "id": "abc123", "variables": { "userId": "123" } }
# Server looks up query by hash from allowlist
Anti-Patterns
| Anti-Pattern | Consequence | Fix |
|---|---|---|
| No query depth limiting | Denial of service via deep nesting | Set max query depth (10-15) |
| No DataLoader | N+1 queries, slow responses | DataLoader for every batched resolver |
| Exposing database schema | Tight coupling, security risk | Domain-oriented schema design |
| No persisted queries | Arbitrary query attacks | Persisted/allowlisted queries in production |
| Single monolithic schema | Team bottleneck | Schema federation for ownership |
GraphQL is a powerful API layer — but power requires discipline. Without depth limiting, DataLoader, and careful schema design, GraphQL can be slower and more dangerous than the REST API it replaced.