Skip to main content

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

  1. Data might not exist: middleName: String
  2. External service might fail: externalRating: Float
  3. Permission-gated: salary: Int (null if not authorized)
  4. 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

  1. Add new field
  2. Deprecate old field with migration instructions
  3. Monitor usage of deprecated field
  4. 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:

More implementation guides coming soon for Node.js, Python, and other platforms.