GraphQL API Design & Best Practices
Design production GraphQL APIs. Covers schema design, resolver patterns, N+1 problem, pagination, authentication, rate limiting, and GraphQL vs REST decision framework.
GraphQL lets clients request exactly the data they need — no over-fetching, no under-fetching. But the flexibility that makes GraphQL powerful also makes it dangerous: a single query can trigger thousands of database calls, exhaust server resources, or expose data the client shouldn’t see. This guide covers how to build GraphQL APIs that are fast, secure, and maintainable.
Schema Design
type Query {
# Single resource by ID
order(id: ID!): Order
# Paginated list with filters
orders(
first: Int = 20
after: String
filter: OrderFilter
): OrderConnection!
}
type OrderConnection {
edges: [OrderEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type OrderEdge {
node: Order!
cursor: String!
}
type Order {
id: ID!
status: OrderStatus!
total: Money!
customer: Customer! # Resolved lazily
items: [OrderItem!]! # Resolved lazily
createdAt: DateTime!
}
enum OrderStatus {
PENDING
CONFIRMED
SHIPPED
DELIVERED
CANCELLED
}
input OrderFilter {
status: OrderStatus
createdAfter: DateTime
customerId: ID
}
N+1 Problem → DataLoader
# WITHOUT DataLoader: N+1 queries
# Query: { orders { customer { name } } }
# 1 query: SELECT * FROM orders
# N queries: SELECT * FROM customers WHERE id = ? (once per order)
# WITH DataLoader: 2 queries total
from graphql import DataLoader
class CustomerLoader(DataLoader):
async def batch_load(self, customer_ids):
# Single query: SELECT * FROM customers WHERE id IN (...)
customers = await db.fetch_all(
"SELECT * FROM customers WHERE id = ANY($1)",
customer_ids
)
customer_map = {c.id: c for c in customers}
return [customer_map.get(id) for id in customer_ids]
# Resolver
def resolve_customer(order, info):
return info.context.customer_loader.load(order.customer_id)
Security
Query Complexity Limiting
# Prevent expensive queries
COMPLEXITY_LIMIT = 1000
# Each field has a cost:
# - Scalar field: 1
# - Object field: 5
# - List field: 10 * estimated items
# This query would be rejected:
# query {
# orders(first: 100) { # 10 * 100 = 1000
# items { # 10 * 5 = 50 per order = 5000
# product { # 5 per item
# reviews { # 10 * 10 = 100 per product
# }
# }
# }
# }
# }
# Total: ~6000 > 1000 limit → REJECTED
GraphQL vs REST Decision
| Factor | Choose REST | Choose GraphQL |
|---|---|---|
| Client diversity | Few clients, similar needs | Many clients, different data needs |
| Data relationships | Flat resources | Deeply nested, interconnected data |
| Caching | HTTP caching critical | Client-side caching acceptable |
| Team experience | REST experience | GraphQL experience or willingness to learn |
| API consumers | External / public API | Internal / mobile apps |
Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
| No query depth limit | Infinitely nested queries crash server | Set max depth (typically 5-10) |
| No DataLoader | N+1 database queries per request | DataLoader for all relationship resolvers |
| Exposing database schema | Schema changes break clients | Separate GraphQL schema from DB schema |
| No pagination | Single query returns millions of rows | Cursor-based pagination (Relay spec) |
| Mutations returning void | Client must re-fetch after mutation | Return updated object from mutations |
Checklist
- Schema design: types, connections, input types, enums
- DataLoader for all relationship resolvers (N+1 prevention)
- Pagination: cursor-based for all list queries
- Query complexity analysis with limits
- Query depth limiting (max 5-10 levels)
- Authentication and field-level authorization
- Error handling: structured errors with codes
- Rate limiting: per-query or per-complexity-unit
:::note[Source] This guide is derived from operational intelligence at Garnet Grid Consulting. For API design consulting, visit garnetgrid.com. :::