Schema Design Best Practices
Learn proven patterns and conventions for designing effective GraphQL schemas.
Schema-First Design
Start with the schema, not the implementation.
┌─────────────────────────────────────────────────────────────────────┐
│ DESIGN PROCESS │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ❌ Implementation-first (avoid) │
│ Database tables → Generate schema → Hope it works │
│ │
│ ✅ Schema-first (recommended) │
│ Understand use cases → Design schema → Implement resolvers │
│ │
│ The schema is a product. Design it for consumers. │
│ │
└─────────────────────────────────────────────────────────────────────┘
Think in Terms of Use Cases
Before writing schema, ask:
- What operations do clients need?
- What data do they need for each screen?
- What relationships matter to them?
Naming Conventions
Consistent naming makes schemas intuitive.
Types: PascalCase
# ✅ Good
type Movie { ... }
type UserProfile { ... }
type CreateMovieInput { ... }
# ❌ Bad
type movie { ... }
type user_profile { ... }
type createMovieInput { ... }
Fields: camelCase
type Movie {
# ✅ Good
releaseYear: Int!
originalTitle: String
# ❌ Bad
release_year: Int!
ReleaseYear: Int!
}
Enums: SCREAMING_SNAKE_CASE
# ✅ Good
enum Genre {
ACTION
SCIENCE_FICTION
ROMANTIC_COMEDY
}
# ❌ Bad
enum Genre {
action
ScienceFiction
romantic-comedy
}
Arguments: camelCase
# ✅ Good
movies(releaseYear: Int, sortBy: MovieSort): [Movie!]!
# ❌ Bad
movies(release_year: Int, SortBy: MovieSort): [Movie!]!
Nullability Strategy
Be intentional about what can be null.
Default to Non-Null
Fields should be non-null unless there's a good reason:
type Movie {
# Always present
id: ID!
title: String!
releaseYear: Int!
# Legitimately optional
sequel: Movie # Not all movies have sequels
originalTitle: String # Same as title if not translated
endDate: Date # Series might still be running
}
When to Use Nullable
- Data might not exist:
middleName: String - External service might fail:
externalRating: Float - Permission-gated:
salary: Int(null if not authorized) - Not yet loaded: Progressive loading patterns
List Nullability
type Movie {
# ✅ Recommended: Non-null list of non-null items
actors: [Actor!]! # Always returns a list (may be empty)
# Use when list itself might not exist
nominations: [Award!] # null means "unknown", [] means "none"
}
Relationships Over IDs
Prefer object relationships over foreign keys:
# ❌ Exposes database structure
type Movie {
id: ID!
title: String!
directorId: ID! # Just an ID
actorIds: [ID!]! # Just IDs
}
# ✅ Expresses relationships
type Movie {
id: ID!
title: String!
director: Director! # Traversable relationship
actors: [Actor!]! # Can query actor details
}
Benefits:
- Single request for related data
- Client doesn't need to know IDs
- Schema documents relationships
Pagination
Always paginate lists that could grow large.
Simple: Offset Pagination
type Query {
movies(offset: Int = 0, limit: Int = 20): MoviePage!
}
type MoviePage {
items: [Movie!]!
totalCount: Int!
hasMore: Boolean!
}
Good for: Small datasets, simple UIs
Robust: Cursor Pagination (Connections)
type Query {
movies(first: Int, after: String, last: Int, before: String): MovieConnection!
}
type MovieConnection {
edges: [MovieEdge!]!
pageInfo: PageInfo!
totalCount: Int
}
type MovieEdge {
node: Movie!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
Good for: Large datasets, real-time data, infinite scroll
Input Design
Use Input Types for Mutations
# ❌ Too many arguments
mutation createMovie(
title: String!
releaseYear: Int!
genre: Genre!
directorId: ID!
# ... many more
): Movie!
# ✅ Organized input type
mutation createMovie(input: CreateMovieInput!): Movie!
input CreateMovieInput {
title: String!
releaseYear: Int!
genre: Genre!
directorId: ID!
}
Separate Create and Update Inputs
# Create: required fields are non-null
input CreateMovieInput {
title: String! # Required
releaseYear: Int! # Required
genre: Genre! # Required
rating: Float # Optional
}
# Update: all fields nullable (partial update)
input UpdateMovieInput {
title: String # Update if provided
releaseYear: Int
genre: Genre
rating: Float
}
Mutation Design
Return the Modified Object
# ✅ Good: Returns the created object
mutation {
createMovie(input: $input): Movie!
}
# ❌ Less useful: Just returns ID
mutation {
createMovie(input: $input): ID!
}
Use Payload Types for Complex Results
type Mutation {
createMovie(input: CreateMovieInput!): CreateMoviePayload!
}
type CreateMoviePayload {
movie: Movie
userErrors: [UserError!]!
}
type UserError {
field: [String!]!
message: String!
}
Naming: Verb + Noun
type Mutation {
# ✅ Clear actions
createMovie(input: CreateMovieInput!): Movie!
updateMovie(id: ID!, input: UpdateMovieInput!): Movie
deleteMovie(id: ID!): DeletePayload!
publishMovie(id: ID!): Movie!
addActorToMovie(movieId: ID!, actorId: ID!): Movie!
# ❌ Unclear
movie(input: MovieInput!): Movie!
movieMutation(action: String!, input: MovieInput!): Movie!
}
Documentation
Document everything. It's exposed via introspection.
"""
A movie in the catalog.
Movies can be queried, created, updated, and deleted.
"""
type Movie {
"Unique identifier for the movie"
id: ID!
"The movie's display title"
title: String!
"""
Year the movie was released.
For movies released across multiple years (re-releases),
this is the original release year.
"""
releaseYear: Int!
"Average user rating (0-10 scale)"
rating: Float
"Reviews submitted by users"
reviews(
"Maximum number of reviews to return"
limit: Int = 10
"Sort order for reviews"
sortBy: ReviewSort = NEWEST
): [Review!]!
}
Deprecation
Never remove fields immediately. Deprecate first.
type Movie {
"The movie's title"
title: String!
"""
The movie's full title.
@deprecated Use `title` instead. Will be removed in v3.
"""
fullTitle: String @deprecated(reason: "Use `title` instead")
"List of genres"
genres: [Genre!]!
"Primary genre"
genre: Genre @deprecated(reason: "Use `genres[0]` instead")
}
Deprecation Process
- Add new field
- Deprecate old field with migration instructions
- Monitor usage of deprecated field
- Remove after usage drops / sufficient time passes
Schema Evolution
GraphQL enables evolution without versioning.
Safe Changes (Non-Breaking)
- Add new types
- Add new fields to existing types
- Add new optional arguments
- Add new enum values (careful!)
- Deprecate fields
Breaking Changes (Avoid)
- Remove types or fields
- Rename types or fields
- Change field types
- Make nullable field non-null
- Remove enum values
- Add required arguments
If You Must Make Breaking Changes
- Communicate well in advance
- Provide migration path
- Consider a new field instead of changing existing
Common Patterns
Node Interface (Global IDs)
interface Node {
id: ID!
}
type Movie implements Node {
id: ID! # Globally unique: "Movie:123"
title: String!
}
type Query {
node(id: ID!): Node
}
Enables refetching any object by ID.
Viewer Pattern
type Query {
viewer: Viewer
}
type Viewer {
id: ID!
user: User!
feed: [FeedItem!]!
notifications: [Notification!]!
settings: UserSettings!
}
Groups user-specific queries.
Error Union Pattern
union CreateMovieResult = Movie | ValidationError | PermissionError
type ValidationError {
message: String!
field: String!
}
type PermissionError {
message: String!
requiredPermission: String!
}
Type-safe error handling.
Anti-Patterns to Avoid
1. God Types
# ❌ Too many fields
type Query {
movie(id: ID!): Movie
movies: [Movie!]!
moviesByGenre(genre: Genre!): [Movie!]!
moviesByYear(year: Int!): [Movie!]!
moviesByRating(minRating: Float!): [Movie!]!
topMovies: [Movie!]!
recentMovies: [Movie!]!
# ... 50 more movie queries
}
# ✅ Use arguments for filtering
type Query {
movie(id: ID!): Movie
movies(
filter: MovieFilter
orderBy: MovieOrder
first: Int
after: String
): MovieConnection!
}
2. Anemic Types
# ❌ Just IDs, no relationships
type Movie {
id: ID!
title: String!
directorId: ID! # Can't traverse
actorIds: [ID!]! # Can't traverse
}
# ✅ Rich relationships
type Movie {
id: ID!
title: String!
director: Director!
actors: [Actor!]!
}
3. RPC-Style Mutations
# ❌ Generic action field
type Mutation {
movieAction(action: String!, movieId: ID!, data: JSON): Result
}
# ✅ Specific mutations
type Mutation {
createMovie(input: CreateMovieInput!): Movie!
publishMovie(id: ID!): Movie!
archiveMovie(id: ID!): Movie!
}
Summary Checklist
┌─────────────────────────────────────────────────────────────────────┐
│ SCHEMA DESIGN CHECKLIST │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Naming │
│ □ Types: PascalCase │
│ □ Fields/Arguments: camelCase │
│ □ Enums: SCREAMING_SNAKE_CASE │
│ │
│ Nullability │
│ □ Default to non-null │
│ □ Lists: [Item!]! for most cases │
│ □ Nullable only when semantically appropriate │
│ │
│ Relationships │
│ □ Objects over IDs │
│ □ Bidirectional where useful │
│ │
│ Mutations │
│ □ Use input types │
│ □ Return modified object │
│ □ Verb + noun naming │
│ │
│ Documentation │
│ □ Every type documented │
│ □ Every field documented │
│ □ Arguments documented │
│ │
│ Evolution │
│ □ Deprecate before removing │
│ □ No breaking changes │
│ │
└─────────────────────────────────────────────────────────────────────┘
What's Next?
You now have a solid foundation in GraphQL concepts! To put this knowledge into practice with a specific technology, explore our implementation tutorials:
- Spring GraphQL Tutorial - Build production-ready GraphQL APIs with Java and Spring Boot
More implementation guides coming soon for Node.js, Python, and other platforms.