Design production GraphQL APIs. Covers schema design, resolver patterns, N+1 problem, pagination, authentication, rate limiting, and GraphQL vs REST decision framework.
GraphQL API Design & Best Practices
GraphQL is a query language for APIs and a runtime for executing those queries with your existing data. It is most valuable when clients need flexible, efficient, and self-describing data access—especially in mobile apps, SPAs, and microservices ecosystems where network latency and client diversity are critical. Use this guide when designing a GraphQL API from scratch, evolving an existing one, or diagnosing performance and usability issues.
The schema is the contract between client and server. It defines types, operations, and the structure of data. Start with a clear, versioned schema that reflects your domain model.
Use type for object types and input for argument objects. Input objects support nested, reusable argument shapes.
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: DateTime!
}
input CreatePostInput {
title: String!
content: String!
tags: [String!]!
authorId: ID!
}
type Post {
id: ID!
title: String!
content: String!
tags: [String!]!
author: User!
publishedAt: DateTime!
}
Use interfaces to model polymorphic data:
interface Node {
id: ID!
createdAt: DateTime!
}
type User implements Node {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: DateTime!
}
type Post implements Node {
id: ID!
title: String!
content: String!
tags: [String!]!
author: User!
publishedAt: DateTime!
createdAt: DateTime!
}
Define custom scalar types for domain-specific data:
scalar DateTime
scalar JSON
scalar UUID
type User {
id: ID!
name: String!
preferences: JSON!
avatarId: UUID!
lastLogin: DateTime!
}
Clients can resolve DateTime and UUID using standard formats (ISO 8601, RFC 4122), and JSON can be passed as raw objects or strings.
Use ! to enforce non-null fields. Combine with @default to supply fallbacks.
type Query {
users(
first: Int = 10
after: String
filter: UserFilterInput
): UserConnection!
user(id: ID!): User
}
input UserFilterInput {
name: String
status: UserStatus = ACTIVE
role: UserRole
}
enum UserStatus {
ACTIVE
INACTIVE
PENDING
BANNED
}
enum UserRole {
ADMIN
MODERATOR
USER
GUEST
}
The @default directive is applied server-side, and clients may omit fields that have defaults.
Clients should be able to fetch precisely the data they need, minimizing over-fetching and under-fetching.
Avoid over-fetching by selecting only required fields:
query GetPostWithAuthorAndComments {
post(id: "1") {
id
title
content
publishedAt
author {
id
name
email
}
comments(first: 5) {
id
content
author {
name
}
}
}
}
Use fragments to reuse field sets:
fragment BasicUser on User {
id
name
email
}
fragment PostSummary on Post {
id
title
publishedAt
author {
...BasicUser
}
}
query GetRecentPosts {
recentPosts(first: 10) {
edges {
node {
...PostSummary
}
}
}
}
For large datasets, use cursor-based pagination (first, after, last, before) instead of offset-based.
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
}
type UserEdge {
node: User!
cursor: String!
}
Clients request:
query GetUsersAfter($cursor: String) {
users(first: 10, after: $cursor) {
edges {
node {
id
name
email
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
The server returns a String cursor (e.g., base64(encode(“User:123”))) that clients use to fetch the next page.
Mutations are the primary way to create, update, and delete data.
Use input objects for complex mutations:
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
post {
id
title
content
publishedAt
author {
id
name
}
}
success
message
errors {
field
message
}
}
}
Return a MutationResponse object to convey status and metadata:
type MutationResponse {
success: Boolean!
message: String
errors: [Error!]!
timestamp: DateTime!
}
type Error {
field: String
message: String!
}
Use errors field at the top level to report validation and execution issues:
{
"data": {
"createPost": {
"post": {
"id": "5",
"title": "GraphQL Best Practices",
"content": "..."
},
"success": true,
"message": "Post created successfully",
"errors": []
}
},
"errors": [
{
"message": "Validation failed",
"locations": [
{
"line": 2,
"column": 3
}
],
"path": [
"createPost"
],
"extensions": {
"code": "VALIDATION_ERROR",
"exception": {
"message": "Input validation failed",
"details": [
{
"field": "title",
"message": "Title is required"
}
]
}
}
}
]
}
The extensions field allows custom metadata such as error codes, trace IDs, and timestamps.
GraphQL schemas evolve over time. Design for backward compatibility.
When adding a new field, provide a default value so old clients work without changes.
type User {
id: ID!
name: String!
email: String!
avatarUrl: String
createdAt: DateTime!
updatedAt: DateTime!
metadata: JSON! = {}
}
Old clients that don’t request metadata receive a default {}.
Mark fields as deprecated with @deprecated(reason: String):
type User {
id: ID!
name: String!
email: String!
createdAt: DateTime!
@deprecated(reason: "Use metadata instead")
profile: JSON!
metadata: JSON! = {}
}
Clients can use the @deprecated directive in their tooling to warn users about usage of profile.
Use @experimental for new, not-yet-stable features:
type User {
id: ID!
name: String!
@experimental
searchScore: Float
}
In microservices, stitch schemas from multiple services:
type Query {
user(id: ID!): User
product(id: ID!): Product
}
type User {
id: ID!
name: String!
email: String!
orders: [Order!]!
}
type Product {
id: ID!
name: String!
price: Float!
inStock: Boolean!
}
Federation enables a unified graph from multiple services by defining @key and @external directives.
extend type User @key(fields: "id") {
id: ID! @external
name: String! @external
email: String! @external
orders: [Order!]! @requires(fields: "id")
}
extend type Order @key(fields: "id") {
id: ID! @external
total: Float! @external
user: User! @requires(fields: "userId")
}
Clients query across services seamlessly:
query GetUserWithOrders {
user(id: "1") {
id
name
email
orders {
id
total
user {
name
}
}
}
}
The gateway resolves the user and orders from separate services.
GraphQL APIs must scale under real load. Monitor and tune for latency, caching, and query complexity.
Use DataLoader to avoid N+1 queries. Batch requests for related data:
const userLoader = new DataLoader(async (userIds) => {
const users = await db.query('SELECT * FROM users WHERE id = ANY($1)', [userIds]);
return userIds.map(id => users.find(u => u.id === id));
});
In resolvers:
const resolvers = {
Post: {
author: (post, _, { userLoader }) => userLoader.load(post.authorId),
comments: (post, _, { commentLoader }) => commentLoader.load(post.id),
},
};
Clients send a single query with multiple fields, and the server batches database calls.
Use HTTP Cache-Control headers to enable client and CDN caching:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=60, stale-while-revalidate=300
ETag: "abc123"
{
"data": {
"user": {
"id": "1",
"name": "Alice"
}
}
}
Clients cache responses and revalidate with If-None-Match and If-Modified-Since.
Implement query complexity tracking to detect expensive queries:
const complexity = {
Query: {
user: 1,
posts: 2,
recentPosts: 3,
},
User: {
name: 1,
email: 1,
posts: 2,
createdAt: 1,
},
Post: {
title: 2,
content: 5,
author: 1,
comments: 3,
},
};
const complexityLimit = 20;
The server calculates total query complexity:
query GetComplexUserWithPosts {
user(id: "1") {
name
email
posts(first: 10) {
title
content
comments(first: 5) {
content
}
}
}
}
Total complexity: 1 (user) + 1 (name) + 1 (email) + 10 × 2 (posts) + 10 × (2 + 5 + 3) = 1 + 1 + 1 + 20 + 100 = 123.
If complexity exceeds 20, log and alert.
@include and @skip directives.@connection directives or expanding queries.@deprecated.data or errors, but not in a standard shape. Fix by using errors at root and extensions for metadata.offset instead of after, leading to inconsistent results. Fix by documenting the cursor format and using base64 encoding.input types for complex arguments.Node interface for polymorphic data.first, after, pageInfo.MutationResponse with success, message, and errors.@deprecated and @experimental to schema.Cache-Control headers on responses.extensions in errors for traceability.@description and @example.A GraphQL API is not just a set of endpoints—it is a contract, a data language, and a performance engine. Design it to be resilient, efficient, and evolving.
This page was rewritten on 15 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy