Designing Great GraphQL Schemas
Your GraphQL schema is the contract between frontend and backend teams. A well-designed schema is intuitive, consistent, and evolvable. A poorly designed schema leads to confusion, breaking changes, and technical debt. These best practices are drawn from real-world production GraphQL APIs.
1. Think in Graphs, Not Endpoints
The biggest mistake when transitioning from REST to GraphQL is mapping REST endpoints directly to queries. Instead, think about your data as a graph of interconnected nodes:
# BAD: REST-style thinking — flat, disconnected types
type Query {
getUser(id: ID!): User
getUserPosts(userId: ID!): [Post!]!
getUserFollowers(userId: ID!): [User!]!
getPostComments(postId: ID!): [Comment!]!
}
# GOOD: Graph thinking — connected, navigable types
type Query {
user(id: ID!): User
post(id: ID!): Post
}
type User {
id: ID!
name: String!
posts(first: Int = 10, after: String): PostConnection!
followers(first: Int = 10, after: String): UserConnection!
following(first: Int = 10, after: String): UserConnection!
}
type Post {
id: ID!
title: String!
author: User!
comments(first: Int = 10, after: String): CommentConnection!
tags: [Tag!]!
}
2. Naming Conventions
Consistent Naming Rules
- Types: PascalCase —
User,BlogPost,CommentThread - Fields: camelCase —
firstName,createdAt,isPublished - Enums: PascalCase type, SCREAMING_SNAKE values —
enum Status { ACTIVE, INACTIVE } - Input types: Verb + Noun + Input —
CreatePostInput,UpdateUserInput - Mutations: verb + noun —
createPost,deleteComment,archiveProject - Queries: noun or noun + qualifier —
user,posts,currentUser
3. Mutation Design Patterns
# BAD: Too many individual arguments
type Mutation {
createPost(title: String!, body: String!, tags: [String!], isPublished: Boolean): Post!
}
# GOOD: Single input argument with a payload return type
type Mutation {
createPost(input: CreatePostInput!): CreatePostPayload!
}
input CreatePostInput {
title: String!
body: String!
tags: [String!]
isPublished: Boolean = false
}
# Payload includes the mutated object and any errors
type CreatePostPayload {
post: Post
userErrors: [UserError!]!
}
type UserError {
field: [String!]
message: String!
code: ErrorCode!
}
enum ErrorCode {
INVALID
REQUIRED
TOO_LONG
TOO_SHORT
ALREADY_EXISTS
NOT_FOUND
UNAUTHORIZED
}
4. Nullability Strategy
# Rule: Only use non-null (!) when you can ALWAYS guarantee a value
type User {
id: ID! # Always exists
name: String! # Required field, always has a value
email: String! # Required field
bio: String # Nullable — user might not have set a bio
avatarUrl: String # Nullable — might not have uploaded an avatar
posts: [Post!]! # Non-null list of non-null items (empty array, never null)
latestPost: Post # Nullable — user might have no posts
}
type Query {
user(id: ID!): User # Nullable — user might not exist
users: [User!]! # Non-null list — always returns array (possibly empty)
me: User # Nullable — user might not be authenticated
}
Nullability Guidelines
- IDs are always non-null: If an object exists, it always has an ID
- Lists are non-null: Return
[Item!]!— an empty list, not null - Optional fields are nullable: Bio, avatar, and other optional data should be nullable
- Query root fields are nullable: A query for a specific item might return null if it does not exist
- Mutation payloads are non-null: Always return a payload, even if the mutation fails
5. Schema Evolution
# Adding fields is ALWAYS safe (non-breaking)
type User {
id: ID!
name: String!
email: String!
# New field added — existing clients won't request it, so no breakage
phoneNumber: String
}
# Deprecating fields — mark as deprecated before removing
type User {
id: ID!
name: String!
# @deprecated tells clients to stop using this field
fullName: String! @deprecated(reason: "Use 'name' instead")
email: String!
}
# NEVER remove a non-null field or change its type
# NEVER make a nullable field non-null
# NEVER add required arguments to existing fields
6. Pagination and Connections
# Standardize pagination across your schema
type PostConnection {
edges: [PostEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type PostEdge {
cursor: String!
node: Post!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
# Apply consistently to all list fields
type User {
posts(first: Int, after: String, last: Int, before: String): PostConnection!
followers(first: Int, after: String): UserConnection!
}
type Query {
posts(first: Int, after: String, filter: PostFilter): PostConnection!
}
Schema Design Checklist
- Think in graphs: Design connected types, not isolated endpoints
- Be consistent: Apply the same naming, pagination, and error patterns everywhere
- Design for the client: The schema should make common client operations easy
- Use descriptions: Document types and fields with descriptions in SDL
- Evolve, don't version: Add fields, deprecate old ones — never break existing clients