TechLead
Lesson 16 of 20
5 min read
GraphQL

Schema Design Best Practices

Learn principles and patterns for designing maintainable, evolvable, and developer-friendly GraphQL schemas

Designing Great GraphQL Schemas

Your GraphQL schema is the contract between frontend and backend teams. A well-designed schema is intuitive, consistent, and evolvable. A poorly designed schema leads to confusion, breaking changes, and technical debt. These best practices are drawn from real-world production GraphQL APIs.

1. Think in Graphs, Not Endpoints

The biggest mistake when transitioning from REST to GraphQL is mapping REST endpoints directly to queries. Instead, think about your data as a graph of interconnected nodes:

# BAD: REST-style thinking — flat, disconnected types
type Query {
  getUser(id: ID!): User
  getUserPosts(userId: ID!): [Post!]!
  getUserFollowers(userId: ID!): [User!]!
  getPostComments(postId: ID!): [Comment!]!
}

# GOOD: Graph thinking — connected, navigable types
type Query {
  user(id: ID!): User
  post(id: ID!): Post
}

type User {
  id: ID!
  name: String!
  posts(first: Int = 10, after: String): PostConnection!
  followers(first: Int = 10, after: String): UserConnection!
  following(first: Int = 10, after: String): UserConnection!
}

type Post {
  id: ID!
  title: String!
  author: User!
  comments(first: Int = 10, after: String): CommentConnection!
  tags: [Tag!]!
}

2. Naming Conventions

Consistent Naming Rules

  • Types: PascalCase — User, BlogPost, CommentThread
  • Fields: camelCase — firstName, createdAt, isPublished
  • Enums: PascalCase type, SCREAMING_SNAKE values — enum Status { ACTIVE, INACTIVE }
  • Input types: Verb + Noun + Input — CreatePostInput, UpdateUserInput
  • Mutations: verb + noun — createPost, deleteComment, archiveProject
  • Queries: noun or noun + qualifier — user, posts, currentUser

3. Mutation Design Patterns

# BAD: Too many individual arguments
type Mutation {
  createPost(title: String!, body: String!, tags: [String!], isPublished: Boolean): Post!
}

# GOOD: Single input argument with a payload return type
type Mutation {
  createPost(input: CreatePostInput!): CreatePostPayload!
}

input CreatePostInput {
  title: String!
  body: String!
  tags: [String!]
  isPublished: Boolean = false
}

# Payload includes the mutated object and any errors
type CreatePostPayload {
  post: Post
  userErrors: [UserError!]!
}

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

enum ErrorCode {
  INVALID
  REQUIRED
  TOO_LONG
  TOO_SHORT
  ALREADY_EXISTS
  NOT_FOUND
  UNAUTHORIZED
}

4. Nullability Strategy

# Rule: Only use non-null (!) when you can ALWAYS guarantee a value

type User {
  id: ID!            # Always exists
  name: String!      # Required field, always has a value
  email: String!     # Required field
  bio: String        # Nullable — user might not have set a bio
  avatarUrl: String  # Nullable — might not have uploaded an avatar
  posts: [Post!]!    # Non-null list of non-null items (empty array, never null)
  latestPost: Post   # Nullable — user might have no posts
}

type Query {
  user(id: ID!): User       # Nullable — user might not exist
  users: [User!]!           # Non-null list — always returns array (possibly empty)
  me: User                  # Nullable — user might not be authenticated
}

Nullability Guidelines

  • IDs are always non-null: If an object exists, it always has an ID
  • Lists are non-null: Return [Item!]! — an empty list, not null
  • Optional fields are nullable: Bio, avatar, and other optional data should be nullable
  • Query root fields are nullable: A query for a specific item might return null if it does not exist
  • Mutation payloads are non-null: Always return a payload, even if the mutation fails

5. Schema Evolution

# Adding fields is ALWAYS safe (non-breaking)
type User {
  id: ID!
  name: String!
  email: String!
  # New field added — existing clients won't request it, so no breakage
  phoneNumber: String
}

# Deprecating fields — mark as deprecated before removing
type User {
  id: ID!
  name: String!
  # @deprecated tells clients to stop using this field
  fullName: String! @deprecated(reason: "Use 'name' instead")
  email: String!
}

# NEVER remove a non-null field or change its type
# NEVER make a nullable field non-null
# NEVER add required arguments to existing fields

6. Pagination and Connections

# Standardize pagination across your schema
type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

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

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

# Apply consistently to all list fields
type User {
  posts(first: Int, after: String, last: Int, before: String): PostConnection!
  followers(first: Int, after: String): UserConnection!
}

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

Schema Design Checklist

  • Think in graphs: Design connected types, not isolated endpoints
  • Be consistent: Apply the same naming, pagination, and error patterns everywhere
  • Design for the client: The schema should make common client operations easy
  • Use descriptions: Document types and fields with descriptions in SDL
  • Evolve, don't version: Add fields, deprecate old ones — never break existing clients

Continue Learning