Skip to content
C Codeloom
GraphQL

GraphQL Schema Design Best Practices

Practical patterns for designing GraphQL schemas that age well: nullability, pagination, mutations, errors, and evolution without versioning.

·5 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • How to think about nullability
  • Connection-style pagination
  • Mutation payload patterns
  • Modeling errors as part of the schema
  • How to evolve without versioning

Prerequisites

  • Basic GraphQL

A GraphQL schema is a long-lived contract. Once clients ship against a field, removing it is a breaking change. The schema you write in week one shapes years of work, so a few design choices pay back enormously. This post collects the ones that consistently matter.

Nullability is design, not paperwork

Every field in GraphQL is nullable by default. Non-null (!) means “if this is null, the whole query errors.” That sounds bad, but the alternative is “this can be missing for reasons we never explained.”

Make a field non-null only if your server can guarantee a value or wants the query to fail when it cannot. List fields are a common bug source: [Post!]! says “non-null list of non-null posts.” If any single post resolver throws, the entire list comes back as null, which is rarely what you want.

A practical rule: lists [Post] for resilience, [Post!]! for strictness, almost never [Post]!.

Mental model

Queries  --> read shape: connections, fields, nullability
Mutations --> write shape: input objects, payloads, errors
Types    --> domain: nouns with stable IDs and interfaces
Errors   --> business errors in schema; bugs as GraphQL errors
Schema concerns and where they live

Keeping these concerns separate stops mutations from drifting into ad-hoc shapes and queries from becoming verbs.

Pagination: cursors, not offsets

Offset pagination (page=2&limit=20) is fine for human-facing tables and bad for everything else: items shift, counts drift, performance falls off a cliff on big tables. The Relay-style connection model is the established pattern:

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
}

type PostEdge {
  node: Post!
  cursor: String!
}

type PageInfo {
  endCursor: String
  hasNextPage: Boolean!
}

type Query {
  posts(first: Int!, after: String): PostConnection!
}

It looks verbose, but it lets clients page reliably even when the underlying list is changing.

Mutations: inputs and payloads

Every mutation should take a single input: object and return a payload type, not a bare entity. The payload gives you room to add fields without breaking clients.

input CreatePostInput {
  title: String!
  body: String!
  clientMutationId: String
}

type CreatePostPayload {
  post: Post
  userErrors: [UserError!]!
}

type UserError {
  field: [String!]!
  message: String!
}

userErrors is the place to express expected failures (validation, conflicts). A failed mutation that returns userErrors is a successful HTTP response. Reserve GraphQL errors for unexpected failures (the DB is down, a bug). This split lets clients write predictable handling code.

Errors as schema

The “errors as data” pattern goes further with result unions:

union CreatePostResult = Post | ValidationError | RateLimited

type Query {
  createPost(input: CreatePostInput!): CreatePostResult!
}

Clients now must handle each case explicitly. Compilers help. Ad-hoc string matching on error messages disappears. This pattern works particularly well in TypeScript with generated discriminated unions.

IDs and interfaces

Global IDs (type Node { id: ID! }) let any object be refetched by ID. It is the basis of Relay’s caching and a generally good idea even outside Relay.

interface Node { id: ID! }
type Post implements Node { id: ID! title: String! }
type User implements Node { id: ID! name: String! }

Encode the type into the ID (base64("Post:42")) so the server can resolve by ID alone. Clients get a uniform way to refetch any node.

Evolution without versioning

GraphQL clients only see the fields they query. Adding fields is non-breaking. Removing or changing them is. The discipline:

  • Add freely.
  • Deprecate with @deprecated(reason: "...") before removing.
  • Watch metrics on deprecated fields; remove only after usage drops to zero.
  • Never reuse a field name with different semantics.

For new shapes, add a new field rather than mutating the old one. body stays as plain text; bodyV2: Markdown appears for clients that opt in.

Hands-on: a small but complete schema

type Query {
  node(id: ID!): Node
  posts(first: Int!, after: String): PostConnection!
}

type Mutation {
  createPost(input: CreatePostInput!): CreatePostPayload!
}

interface Node { id: ID! }

type Post implements Node {
  id: ID!
  title: String!
  body: String!
  author: User!
  comments(first: Int!, after: String): CommentConnection!
}

type User implements Node { id: ID! name: String! }

Notice: connection-based lists, non-null on solid relations, the Node interface, single mutation input plus payload. Boring is good here.

Common pitfalls

  • [Post]! lists that swallow individual errors as a whole-list null. Prefer [Post] or [Post!]!.
  • Mutations returning bare entities. There is no room to add userErrors later without breaking clients.
  • Verb-y queries (getUserById, fetchPosts). Queries are nouns; verbs go in mutations.
  • Stuffing computed fields onto root Query. Put them on the type they describe: Post.readingTime, not Query.postReadingTime.
  • Treating ID as a database integer. It is a serialized opaque string.
  • Forgetting that nullability changes are breaking in both directions. Promoting a field to non-null can break clients that handled null.

Practical tips

  • Lint your schema. Tools like graphql-schema-linter catch common issues automatically.
  • Write the schema first, in .graphql files. Code generation keeps resolvers honest.
  • Document fields with descriptions. They show up in introspection and clients see them.
  • Track per-field usage. Removing a deprecated field is safe only with data.
  • Run breaking-change checks in CI (graphql-inspector) so PRs flag accidental incompatibilities.

Wrap-up

Good GraphQL schemas read like good API docs: clear nouns, explicit nullability, structured pagination, predictable mutations, and a path for new fields without breaking old clients. Most schema regret comes from skipping these patterns early to ship faster. Spend the day on shape; the next two years get easier.