Skip to main content

Class 10: Schema Design Best Practices

Learn proven patterns and conventions for designing effective GraphQL schemas.

Duration: ~20 minutes | Difficulty: Intermediate | Prerequisites: Class 9


Design the Schema for Its Consumers

The most important habit is to design the schema around consumer use cases instead of around your database tables. This is sometimes called "schema-first design" but the term is overloaded; what matters is the thinking, not the file format.

Design Process

Database-shaped schema (avoid): Database tables → Mirror them as types → Hope clients can compose what they need.

Use-case-shaped schema (recommended): Understand consumer use cases → Design types and operations around them → Implement resolvers however your stack prefers.

The schema is a product. Design it for consumers.

Schema-first vs Code-first

Two implementation styles exist and the GraphQL community is genuinely split:

  • Schema-first: write the SDL .graphql file by hand, then implement resolvers against it. Common in Apollo Server (JavaScript) and Spring for GraphQL (Java).
  • Code-first: define types in your programming language with a builder library (Pothos, Nexus, Strawberry, graphql-ruby, Hot Chocolate, type-graphql), and the SDL is derived from your code.

Both can produce well-designed schemas; both can produce poorly-designed schemas. The use-case-first thinking above applies regardless of which file you start typing in.

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.

Nullability: a contested default

Two reasonable defaults exist and the right answer depends on the field:

  • "Default to nullable" (the position taken in this guide, and the one graphql.org and Apollo both teach: see graphql.org on nullability, Apollo's nullability guide and this blog post): make fields nullable unless correctness genuinely requires non-null. Two arguments carry it. Tightening from nullable to non-null later is forward-compatible, because existing clients keep working, and going the other way is a breaking change. Non-null fields also propagate execution errors up the response tree, so one failing non-null leaf can remove successful sibling fields.
  • "Default to non-null": make every field non-null unless there's a reason it could legitimately be missing. Cleaner client code, and fewer needless null checks in every consumer.

The ecosystem is working on removing the trade-off the two camps are arguing about. @semanticNonNull marks a field that is null only when it errors. A request-level onError parameter would let a client ask the server to write null at the position that failed, instead of propagating the null upward. The dedicated Nullability Working Group closed in February 2026, having settled on the onError plus service-capabilities direction, and the proposal now sits with the main working group. graphql.org's naming lesson declines to prescribe a default at all for this reason. None of it is in a ratified edition yet, so pick a default today and expect the calculus to shift.

Both views agree on the clear cases. IDs and primary keys are almost always non-null, and cross-service joins are almost always nullable. The disagreement is about everything in between. Pick one default, document it, and apply it consistently.

The examples below apply that default: ! where the Movie Database can genuinely promise a value, plain types everywhere else.

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
type Mutation {
createMovie(
title: String!
releaseYear: Int!
genre: Genre!
directorId: ID!
# ... many more
): Movie!
}

# ✅ Organized input type
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
type Mutation {
createMovie(input: CreateMovieInput!): Movie!
}

# ❌ Less useful: Just returns ID
type Mutation {
createMovie(input: CreateMovieInput!): ID!
}

Use Payload Types for Complex Results

type Mutation {
createMovie(input: CreateMovieInput!): CreateMoviePayload!
}

type CreateMoviePayload {
movie: Movie
userErrors: [UserError!]!
}

type UserError {
# The list itself is nullable: a user error may not be tied to any specific input field.
field: [String!]
message: String!
}

Naming: Verb + Noun (or Noun + Verb)

Two conventions are widely used and the GraphQL community is genuinely split between them:

  • Verb + Noun (createMovie, deleteMovie): used by Apollo's docs and most graphql.org examples. Reads naturally as English.
  • Noun + Verb (movieCreate, movieDelete): recommended by Shopify's design tutorial and used by Shopify's own Admin API (productCreate, productUpdate, productSet). graphql.org's naming conventions lesson lists it as one of two sanctioned strategies and credits it to Shopify. The motivation is alphabetical grouping in introspection and documentation: all movie* mutations cluster together. GitHub sits firmly in the other camp, with createIssue, addComment and deleteIssue.

Pick one and apply it consistently across your schema. The examples below use 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` instead and select the first item on the client")
}

The September 2025 edition extended @deprecated to field arguments and input object fields, so an argument can be retired without inventing a parallel field, and an input field without a whole new input type:

type Query {
movies(
genre: Genre @deprecated(reason: "Use `genres` instead")
genres: [Genre!]
): [Movie!]!
}

input UpdateMovieInput {
title: String
rating: Float @deprecated(reason: "Ratings are computed from reviews")
}

Only optional arguments and input fields may be deprecated, since a client cannot stop supplying a required one.

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 is designed for continuous evolution instead of version bumps. The strong typing and field selection model make many changes additive. That said, real-world schemas do hit changes that genuinely break clients, and large public APIs handle that in two different ways. Shopify versions its GraphQL Admin API with date-stamped releases such as 2026-04, ships one each quarter, and supports each for at least twelve months. GitHub keeps a single unversioned schema and instead announces breaking changes at least three months ahead, applying them on the first day of a quarter.

Safe Changes (Non-Breaking)

  • Add new types
  • Add new fields to existing types (with caveat: clients using introspection-driven exhaustive type checks may need to regenerate types)
  • Add new optional input arguments
  • Deprecate fields with @deprecated

Dangerous Changes (Won't break valid clients today, but may break some)

  • Add a new enum value to an output enum: clients with exhaustive switch statements or strictly-typed generated clients can break at runtime when an unknown value arrives. Add new enum values intentionally, ideally behind a feature gate, and communicate them to clients before rollout.
  • Tighten an output field from nullable to non-null: diff tools call this safe. graphql-inspector reports it as non-breaking, and graphql-js leaves it out of both its breaking and its dangerous lists. It still deserves care for a reason the tools do not model. The field now propagates execution errors to its parent, so a resolver failure that used to produce one null leaf can null out the whole object. Generated clients also change shape, so consumers have to regenerate.
  • Add an interface implementation to an object type: existing queries keep working. A fragment spread on that interface elsewhere in the schema now matches a type it never matched before, and clients dispatching on __typename meet a case they were not written for.

Breaking Changes (Avoid)

  • Remove types or fields
  • Rename types or fields
  • Change field types incompatibly
  • Tighten an input type from nullable to non-null
  • Remove enum values (input or output)
  • Add required input 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 opaque ID (typically base64-encoded)
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 nullable, with ! where a value is genuinely guaranteed
  • 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.