Skip to content
Codeloom
GraphQL

GraphQL Schema Design Best Practices

Design GraphQL schemas that scale: types, queries, mutations, connections, pagination, nullability, naming conventions, and patterns for evolving your API over time.

·7 min read · By Codeloom
Intermediate 14 min read

What you'll learn

  • How to structure GraphQL types for real-world domains
  • Query and mutation design patterns
  • Relay-style connections for pagination
  • Nullability decisions and their tradeoffs
  • Schema evolution without breaking clients

Prerequisites

  • Basic GraphQL concepts (queries, mutations, types)
  • Understanding of API design principles
  • Some experience consuming or building APIs

A GraphQL schema is your API’s contract. Unlike REST, where the structure is implicit in URLs and documentation, GraphQL makes the contract explicit and machine-readable. Getting the schema right means your API is intuitive to use, efficient to resolve, and possible to evolve without breaking existing clients.

Type design

Start with your domain objects. Each type should represent a clear concept.

type User {
  id: ID!
  email: String!
  name: String!
  avatarUrl: String
  role: UserRole!
  createdAt: DateTime!
  updatedAt: DateTime!
}

enum UserRole {
  ADMIN
  EDITOR
  VIEWER
}

type Post {
  id: ID!
  title: String!
  slug: String!
  content: String!
  status: PostStatus!
  author: User!
  tags: [Tag!]!
  publishedAt: DateTime
  createdAt: DateTime!
  updatedAt: DateTime!
}

enum PostStatus {
  DRAFT
  PUBLISHED
  ARCHIVED
}

type Tag {
  id: ID!
  name: String!
  slug: String!
  postCount: Int!
}

Naming conventions

  • Types: PascalCase (User, BlogPost)
  • Fields: camelCase (firstName, createdAt)
  • Enums: SCREAMING_SNAKE_CASE (PUBLISHED, IN_PROGRESS)
  • Input types: suffix with Input (CreateUserInput)
  • Connections: suffix with Connection (UserConnection)
  • Edges: suffix with Edge (UserEdge)

Query design

Queries are the read side of your API. Design them around the use cases, not the database tables.

type Query {
  # Single resource by ID
  user(id: ID!): User
  post(id: ID!): Post
  postBySlug(slug: String!): Post

  # Collections with filtering and pagination
  users(
    filter: UserFilter
    first: Int
    after: String
    orderBy: UserOrderBy
  ): UserConnection!

  posts(
    filter: PostFilter
    first: Int
    after: String
    orderBy: PostOrderBy
  ): PostConnection!

  # Domain-specific queries
  me: User
  searchPosts(query: String!, first: Int, after: String): PostConnection!
  trendingPosts(period: TimePeriod!, first: Int): [Post!]!
}

input UserFilter {
  role: UserRole
  search: String
  createdAfter: DateTime
}

input PostFilter {
  status: PostStatus
  authorId: ID
  tagIds: [ID!]
  publishedAfter: DateTime
}

enum UserOrderBy {
  CREATED_AT_ASC
  CREATED_AT_DESC
  NAME_ASC
  NAME_DESC
}

enum TimePeriod {
  DAY
  WEEK
  MONTH
}

Design principles for queries

Return nullable for single lookups. user(id: ID!): User returns null if the user does not exist. This is better than throwing an error for a missing resource.

Return non-nullable for collections. posts(...): PostConnection! always returns a connection object (which may contain zero edges). The connection itself is never null.

Provide a me query. Authenticated users should be able to fetch their own data without knowing their ID.

Mutation design

Mutations are the write side. Each mutation should represent a specific action, not a generic CRUD operation.

type Mutation {
  # User mutations
  createUser(input: CreateUserInput!): CreateUserPayload!
  updateUser(input: UpdateUserInput!): UpdateUserPayload!
  deleteUser(id: ID!): DeleteUserPayload!
  changeUserRole(userId: ID!, role: UserRole!): ChangeUserRolePayload!

  # Post mutations
  createPost(input: CreatePostInput!): CreatePostPayload!
  updatePost(input: UpdatePostInput!): UpdatePostPayload!
  publishPost(id: ID!): PublishPostPayload!
  unpublishPost(id: ID!): UnpublishPostPayload!
  deletePost(id: ID!): DeletePostPayload!

  # Authentication
  login(email: String!, password: String!): AuthPayload!
  signup(input: SignupInput!): AuthPayload!
}

Input types

input CreatePostInput {
  title: String!
  content: String!
  tagIds: [ID!]
  status: PostStatus = DRAFT
}

input UpdatePostInput {
  id: ID!
  title: String
  content: String
  tagIds: [ID!]
}

For updates, make all fields except the ID optional. Only the provided fields are updated. This avoids the “send the entire object back” problem.

Payload types

Every mutation should return a payload type, not the raw object. This gives you room to add metadata, errors, and related data.

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

type UserError {
  field: String
  message: String!
  code: ErrorCode!
}

enum ErrorCode {
  NOT_FOUND
  VALIDATION_ERROR
  UNAUTHORIZED
  CONFLICT
  INTERNAL_ERROR
}

type DeletePostPayload {
  deletedPostId: ID
  errors: [UserError!]!
}

type AuthPayload {
  token: String
  user: User
  errors: [UserError!]!
}

This pattern puts errors in the response body rather than relying on GraphQL-level errors. Clients can handle validation errors without catching exceptions.

mutation {
  createPost(input: { title: "", content: "Hello" }) {
    post {
      id
      title
    }
    errors {
      field
      message
      code
    }
  }
}

# Response:
# {
#   "data": {
#     "createPost": {
#       "post": null,
#       "errors": [
#         { "field": "title", "message": "Title cannot be empty", "code": "VALIDATION_ERROR" }
#       ]
#     }
#   }
# }

Relay-style connections (cursor pagination)

The Relay connection specification is the standard for paginated collections in GraphQL.

type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type UserEdge {
  node: User!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

Query usage:

query {
  users(first: 10, after: "cursor_abc") {
    edges {
      node {
        id
        name
        email
      }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor
    }
    totalCount
  }
}

The cursor is opaque to the client (typically a base64-encoded identifier). The client passes endCursor as the after argument to get the next page.

Why connections over simple lists

  • Pagination metadata: hasNextPage, totalCount are built in
  • Stable pagination: Cursors are stable across insertions and deletions (unlike offset-based)
  • Edge metadata: The edge can carry relationship-specific data (e.g., role in a membership edge)
type TeamMemberEdge {
  node: User!
  cursor: String!
  role: TeamRole!     # Metadata about the relationship
  joinedAt: DateTime!
}

Nullability decisions

Every field is either nullable (String) or non-nullable (String!). The decision matters.

Make fields non-nullable when:

  • The field is always present (id, createdAt)
  • The field has a default (status with a default value)
  • Returning null would be a bug

Make fields nullable when:

  • The field is genuinely optional (avatarUrl, bio)
  • The field might fail to resolve independently (a user’s posts might timeout)
  • You want to return partial results on errors
type User {
  id: ID!           # Always present
  email: String!    # Always present
  name: String!     # Always present, required at signup
  bio: String       # Optional, user might not fill in
  avatarUrl: String # Optional
  company: String   # Optional
  
  # Non-nullable list of non-nullable items
  # Always returns a list, items in the list are never null
  posts: [Post!]!
}

The [Post!]! pattern means: the field always returns a list (outer !), and items in the list are never null (inner !). This is the safest default for list fields.

Interfaces and unions

Use interfaces for types that share common fields. Use unions for types that are fundamentally different but appear in the same context.

interface Node {
  id: ID!
}

interface Timestamped {
  createdAt: DateTime!
  updatedAt: DateTime!
}

type User implements Node & Timestamped {
  id: ID!
  name: String!
  createdAt: DateTime!
  updatedAt: DateTime!
}

type Post implements Node & Timestamped {
  id: ID!
  title: String!
  createdAt: DateTime!
  updatedAt: DateTime!
}

# Union for search results
union SearchResult = User | Post | Tag

type Query {
  search(query: String!): [SearchResult!]!
  node(id: ID!): Node  # Fetch any entity by global ID
}

Schema evolution

GraphQL schemas should evolve without breaking existing clients.

Adding fields is safe. Clients only query fields they know about. New fields are invisible to old clients.

Removing fields is breaking. Use @deprecated first, monitor usage, then remove after clients have migrated.

type User {
  id: ID!
  name: String!
  firstName: String!
  lastName: String!
  fullName: String @deprecated(reason: "Use `name` instead. Will be removed 2026-09-01.")
}

Adding enum values can break clients that do exhaustive switches. Add new values carefully and communicate the change.

Changing nullability from nullable to non-nullable is safe (existing clients already handle null). Going the other direction is breaking.

Good schema design is about making the right things easy and the wrong things impossible. Start with your use cases, model your domain clearly, use connections for pagination, put errors in payloads, and evolve carefully. A well-designed schema is the best documentation your API can have.