GraphQL Schema Design Patterns and Conventions
Master GraphQL schema design with practical patterns for naming, nullability, input types, enums, and evolving schemas without versioning.
What you'll learn
- ✓Naming conventions and patterns for types, fields, and mutations
- ✓How to handle nullability correctly in your schema
- ✓How to design schemas that evolve gracefully without versioning
Prerequisites
- •Basic GraphQL knowledge
- •Experience building at least one GraphQL API
Why Schema Design Matters
Your GraphQL schema is a contract between your backend and every client that consumes it. A poorly designed schema creates friction, forces breaking changes, and makes your API harder to extend. Unlike REST APIs where you can version endpoints, GraphQL encourages a single evolving schema. Getting the design right from the start saves significant refactoring later.
Naming Conventions
Consistency in naming makes your schema predictable. Follow these conventions used by most production GraphQL APIs:
Types use PascalCase: User, BlogPost, OrderItem.
Fields use camelCase: firstName, createdAt, isActive.
Enums use PascalCase for the type name and SCREAMING_SNAKE_CASE for values:
enum OrderStatus {
PENDING
PROCESSING
SHIPPED
DELIVERED
CANCELLED
}
Mutations use a verb-noun pattern: createUser, updatePost, deleteComment, sendInvitation.
Input types are suffixed with Input: CreateUserInput, UpdatePostInput.
Payload types for mutations are suffixed with Payload: CreateUserPayload, DeletePostPayload.
Nullability: Default to Non-Null
In GraphQL, fields are nullable by default. This is the opposite of most programming languages. Be intentional about nullability:
type User {
id: ID! # Always exists, non-null
name: String! # Always exists, non-null
email: String! # Always exists, non-null
bio: String # Nullable, user may not have set one
avatarUrl: String # Nullable, may not have uploaded one
}
A good rule of thumb: make fields non-null (!) unless there is a real reason they might be absent. Non-null fields provide stronger guarantees to clients and eliminate entire classes of null-checking bugs.
However, be careful with fields that involve expensive lookups or external services. If a non-null field fails, the error bubbles up and nullifies the parent object. For risky fields, nullable is safer:
type User {
id: ID!
name: String!
creditScore: Int # Nullable: external service might be down
}
Input Types for Mutations
Always use dedicated input types for mutations rather than individual arguments:
# Bad: too many arguments
type Mutation {
createUser(name: String!, email: String!, role: Role!): User!
}
# Good: single input type
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
}
input CreateUserInput {
name: String!
email: String!
role: Role!
}
type CreateUserPayload {
user: User!
}
Input types are easier to extend without breaking changes. Adding a new optional field to CreateUserInput is backward compatible.
Mutation Payloads
Return a payload type from mutations instead of returning the entity directly. This gives you room to add metadata:
type CreateUserPayload {
user: User!
token: String
errors: [UserError!]!
}
type UserError {
field: String!
message: String!
}
This pattern lets mutations return both the result and any validation errors in a structured way, without relying on GraphQL-level errors for business logic.
Connection Pattern for Lists
For any list that could grow large, use the Connection pattern instead of returning a bare list:
# Bad: no pagination support
type Query {
users: [User!]!
}
# Good: cursor-based pagination
type Query {
users(first: Int, after: String, last: Int, before: String): UserConnection!
}
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
cursor: String!
node: User!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
Even if your list is small today, wrapping it in a connection from the start avoids a breaking change when you inevitably need pagination.
Use Enums Over Strings
Whenever a field has a fixed set of values, use an enum instead of a string:
# Bad
type Order {
status: String! # "pending"? "Pending"? "PENDING"?
}
# Good
type Order {
status: OrderStatus!
}
enum OrderStatus {
PENDING
PROCESSING
SHIPPED
DELIVERED
}
Enums provide type safety, auto-completion in clients, and eliminate typos.
Interfaces and Unions
Use interfaces when types share common fields:
interface Node {
id: ID!
}
interface Timestamped {
createdAt: DateTime!
updatedAt: DateTime!
}
type User implements Node & Timestamped {
id: ID!
name: String!
createdAt: DateTime!
updatedAt: DateTime!
}
Use unions for search results or feeds where types do not share structure:
union SearchResult = User | Post | Comment
type Query {
search(query: String!): [SearchResult!]!
}
Designing for Evolution
Since GraphQL discourages API versioning, design your schema to evolve gracefully:
Adding fields is always safe. Clients that do not request new fields are unaffected.
Deprecate before removing. Use the @deprecated directive to signal that a field will be removed:
type User {
name: String! @deprecated(reason: "Use firstName and lastName instead")
firstName: String!
lastName: String!
}
Never change field types. Changing age: Int to age: String breaks existing clients. Instead, add a new field with the new type.
Never make a nullable field non-null. Clients may not be sending the field. You can safely make a non-null field nullable, but not the reverse.
Avoid Deep Nesting Traps
Design your schema to discourage unnecessarily deep queries:
# Risky: enables very deep nesting
type User {
friends: [User!]! # friends of friends of friends...
}
# Better: use a dedicated query with depth control
type Query {
userFriends(userId: ID!, depth: Int = 1): [User!]!
}
Combine schema design with query depth limiting on the server to prevent abuse.
Custom Scalars
Define custom scalars for domain-specific types:
scalar DateTime
scalar EmailAddress
scalar URL
scalar PositiveInt
Libraries like graphql-scalars provide validated implementations for common scalars. Custom scalars enforce validation at the schema level rather than in every resolver.
Consistent ID Patterns
Use globally unique IDs when possible. This enables client-side caching and the Node interface pattern:
interface Node {
id: ID!
}
type Query {
node(id: ID!): Node
}
With globally unique IDs, clients can refetch any object by its ID without knowing the type, which is the foundation of Relay’s caching strategy.
Summary
Good schema design starts with consistent naming, intentional nullability, and proper use of input and payload types. Use the connection pattern for lists, enums for fixed values, and interfaces for shared fields. Design for evolution by adding rather than modifying, deprecating before removing, and never changing field types. These conventions are used by most production GraphQL APIs and will keep your schema maintainable as it grows.
Related articles
- GraphQL GraphQL Error Handling Patterns and Extensions
Implement robust error handling in GraphQL with error extensions, union-based errors, and structured error responses for better client DX.
- 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 GraphQL Pagination: Relay-Style Connections
Understand cursor-based pagination using the Relay connection spec, why it scales better than offsets, and how to implement it cleanly in your schema.
- GraphQL Authentication and Authorization in GraphQL
Implement auth in GraphQL using context-based authentication, custom directives, and field-level authorization patterns.