GraphQL Schema Design Best Practices
Design GraphQL schemas that scale: types, queries, mutations, connections, pagination, nullability, naming conventions, and patterns for evolving your API over time.
What you'll learn
- ✓How to structure GraphQL types for real-world domains
- ✓Query and mutation design patterns
- ✓Relay-style connections for pagination
- ✓Nullability decisions and their tradeoffs
- ✓Schema evolution without breaking clients
Prerequisites
- •Basic GraphQL concepts (queries, mutations, types)
- •Understanding of API design principles
- •Some experience consuming or building APIs
A GraphQL schema is your API’s contract. Unlike REST, where the structure is implicit in URLs and documentation, GraphQL makes the contract explicit and machine-readable. Getting the schema right means your API is intuitive to use, efficient to resolve, and possible to evolve without breaking existing clients.
Type design
Start with your domain objects. Each type should represent a clear concept.
type User {
id: ID!
email: String!
name: String!
avatarUrl: String
role: UserRole!
createdAt: DateTime!
updatedAt: DateTime!
}
enum UserRole {
ADMIN
EDITOR
VIEWER
}
type Post {
id: ID!
title: String!
slug: String!
content: String!
status: PostStatus!
author: User!
tags: [Tag!]!
publishedAt: DateTime
createdAt: DateTime!
updatedAt: DateTime!
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
type Tag {
id: ID!
name: String!
slug: String!
postCount: Int!
}
Naming conventions
- Types:
PascalCase(User, BlogPost) - Fields:
camelCase(firstName, createdAt) - Enums:
SCREAMING_SNAKE_CASE(PUBLISHED, IN_PROGRESS) - Input types: suffix with
Input(CreateUserInput) - Connections: suffix with
Connection(UserConnection) - Edges: suffix with
Edge(UserEdge)
Query design
Queries are the read side of your API. Design them around the use cases, not the database tables.
type Query {
# Single resource by ID
user(id: ID!): User
post(id: ID!): Post
postBySlug(slug: String!): Post
# Collections with filtering and pagination
users(
filter: UserFilter
first: Int
after: String
orderBy: UserOrderBy
): UserConnection!
posts(
filter: PostFilter
first: Int
after: String
orderBy: PostOrderBy
): PostConnection!
# Domain-specific queries
me: User
searchPosts(query: String!, first: Int, after: String): PostConnection!
trendingPosts(period: TimePeriod!, first: Int): [Post!]!
}
input UserFilter {
role: UserRole
search: String
createdAfter: DateTime
}
input PostFilter {
status: PostStatus
authorId: ID
tagIds: [ID!]
publishedAfter: DateTime
}
enum UserOrderBy {
CREATED_AT_ASC
CREATED_AT_DESC
NAME_ASC
NAME_DESC
}
enum TimePeriod {
DAY
WEEK
MONTH
}
Design principles for queries
Return nullable for single lookups. user(id: ID!): User returns null if the user does not exist. This is better than throwing an error for a missing resource.
Return non-nullable for collections. posts(...): PostConnection! always returns a connection object (which may contain zero edges). The connection itself is never null.
Provide a me query. Authenticated users should be able to fetch their own data without knowing their ID.
Mutation design
Mutations are the write side. Each mutation should represent a specific action, not a generic CRUD operation.
type Mutation {
# User mutations
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(input: UpdateUserInput!): UpdateUserPayload!
deleteUser(id: ID!): DeleteUserPayload!
changeUserRole(userId: ID!, role: UserRole!): ChangeUserRolePayload!
# Post mutations
createPost(input: CreatePostInput!): CreatePostPayload!
updatePost(input: UpdatePostInput!): UpdatePostPayload!
publishPost(id: ID!): PublishPostPayload!
unpublishPost(id: ID!): UnpublishPostPayload!
deletePost(id: ID!): DeletePostPayload!
# Authentication
login(email: String!, password: String!): AuthPayload!
signup(input: SignupInput!): AuthPayload!
}
Input types
input CreatePostInput {
title: String!
content: String!
tagIds: [ID!]
status: PostStatus = DRAFT
}
input UpdatePostInput {
id: ID!
title: String
content: String
tagIds: [ID!]
}
For updates, make all fields except the ID optional. Only the provided fields are updated. This avoids the “send the entire object back” problem.
Payload types
Every mutation should return a payload type, not the raw object. This gives you room to add metadata, errors, and related data.
type CreatePostPayload {
post: Post
errors: [UserError!]!
}
type UserError {
field: String
message: String!
code: ErrorCode!
}
enum ErrorCode {
NOT_FOUND
VALIDATION_ERROR
UNAUTHORIZED
CONFLICT
INTERNAL_ERROR
}
type DeletePostPayload {
deletedPostId: ID
errors: [UserError!]!
}
type AuthPayload {
token: String
user: User
errors: [UserError!]!
}
This pattern puts errors in the response body rather than relying on GraphQL-level errors. Clients can handle validation errors without catching exceptions.
mutation {
createPost(input: { title: "", content: "Hello" }) {
post {
id
title
}
errors {
field
message
code
}
}
}
# Response:
# {
# "data": {
# "createPost": {
# "post": null,
# "errors": [
# { "field": "title", "message": "Title cannot be empty", "code": "VALIDATION_ERROR" }
# ]
# }
# }
# }
Relay-style connections (cursor pagination)
The Relay connection specification is the standard for paginated collections in GraphQL.
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
Query usage:
query {
users(first: 10, after: "cursor_abc") {
edges {
node {
id
name
email
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
The cursor is opaque to the client (typically a base64-encoded identifier). The client passes endCursor as the after argument to get the next page.
Why connections over simple lists
- Pagination metadata:
hasNextPage,totalCountare built in - Stable pagination: Cursors are stable across insertions and deletions (unlike offset-based)
- Edge metadata: The edge can carry relationship-specific data (e.g.,
rolein a membership edge)
type TeamMemberEdge {
node: User!
cursor: String!
role: TeamRole! # Metadata about the relationship
joinedAt: DateTime!
}
Nullability decisions
Every field is either nullable (String) or non-nullable (String!). The decision matters.
Make fields non-nullable when:
- The field is always present (id, createdAt)
- The field has a default (status with a default value)
- Returning null would be a bug
Make fields nullable when:
- The field is genuinely optional (avatarUrl, bio)
- The field might fail to resolve independently (a user’s posts might timeout)
- You want to return partial results on errors
type User {
id: ID! # Always present
email: String! # Always present
name: String! # Always present, required at signup
bio: String # Optional, user might not fill in
avatarUrl: String # Optional
company: String # Optional
# Non-nullable list of non-nullable items
# Always returns a list, items in the list are never null
posts: [Post!]!
}
The [Post!]! pattern means: the field always returns a list (outer !), and items in the list are never null (inner !). This is the safest default for list fields.
Interfaces and unions
Use interfaces for types that share common fields. Use unions for types that are fundamentally different but appear in the same context.
interface Node {
id: ID!
}
interface Timestamped {
createdAt: DateTime!
updatedAt: DateTime!
}
type User implements Node & Timestamped {
id: ID!
name: String!
createdAt: DateTime!
updatedAt: DateTime!
}
type Post implements Node & Timestamped {
id: ID!
title: String!
createdAt: DateTime!
updatedAt: DateTime!
}
# Union for search results
union SearchResult = User | Post | Tag
type Query {
search(query: String!): [SearchResult!]!
node(id: ID!): Node # Fetch any entity by global ID
}
Schema evolution
GraphQL schemas should evolve without breaking existing clients.
Adding fields is safe. Clients only query fields they know about. New fields are invisible to old clients.
Removing fields is breaking. Use @deprecated first, monitor usage, then remove after clients have migrated.
type User {
id: ID!
name: String!
firstName: String!
lastName: String!
fullName: String @deprecated(reason: "Use `name` instead. Will be removed 2026-09-01.")
}
Adding enum values can break clients that do exhaustive switches. Add new values carefully and communicate the change.
Changing nullability from nullable to non-nullable is safe (existing clients already handle null). Going the other direction is breaking.
Good schema design is about making the right things easy and the wrong things impossible. Start with your use cases, model your domain clearly, use connections for pagination, put errors in payloads, and evolve carefully. A well-designed schema is the best documentation your API can have.
Related articles
- GraphQL GraphQL Schemas and Resolvers
A practical guide to GraphQL schemas — SDL, scalar types, Query and Mutation roots, custom object types, resolver functions, and how to avoid the N+1 problem with DataLoader.
- GraphQL GraphQL Resolvers: A Complete Guide
Master GraphQL resolvers: resolver functions, context, the N+1 problem, DataLoader for batching, error handling, authorization in resolvers, and performance patterns.
- GraphQL GraphQL Error Handling Best Practices
Compare the errors array, union result types, and partial responses to design predictable, typed error handling for your GraphQL APIs and clients.
- GraphQL Authentication and Authorization in GraphQL
Implement auth in GraphQL using context-based authentication, custom directives, and field-level authorization patterns.