Skip to content
Codeloom
GraphQL

GraphQL Schema Design Patterns and Conventions

Master GraphQL schema design with practical patterns for naming, nullability, input types, enums, and evolving schemas without versioning.

·6 min read · By Codeloom
Intermediate 11 min read

What you'll learn

  • Naming conventions and patterns for types, fields, and mutations
  • How to handle nullability correctly in your schema
  • How to design schemas that evolve gracefully without versioning

Prerequisites

  • Basic GraphQL knowledge
  • Experience building at least one GraphQL API

Why Schema Design Matters

Your GraphQL schema is a contract between your backend and every client that consumes it. A poorly designed schema creates friction, forces breaking changes, and makes your API harder to extend. Unlike REST APIs where you can version endpoints, GraphQL encourages a single evolving schema. Getting the design right from the start saves significant refactoring later.

Naming Conventions

Consistency in naming makes your schema predictable. Follow these conventions used by most production GraphQL APIs:

Types use PascalCase: User, BlogPost, OrderItem.

Fields use camelCase: firstName, createdAt, isActive.

Enums use PascalCase for the type name and SCREAMING_SNAKE_CASE for values:

enum OrderStatus {
  PENDING
  PROCESSING
  SHIPPED
  DELIVERED
  CANCELLED
}

Mutations use a verb-noun pattern: createUser, updatePost, deleteComment, sendInvitation.

Input types are suffixed with Input: CreateUserInput, UpdatePostInput.

Payload types for mutations are suffixed with Payload: CreateUserPayload, DeletePostPayload.

Nullability: Default to Non-Null

In GraphQL, fields are nullable by default. This is the opposite of most programming languages. Be intentional about nullability:

type User {
  id: ID!           # Always exists, non-null
  name: String!     # Always exists, non-null
  email: String!    # Always exists, non-null
  bio: String       # Nullable, user may not have set one
  avatarUrl: String # Nullable, may not have uploaded one
}

A good rule of thumb: make fields non-null (!) unless there is a real reason they might be absent. Non-null fields provide stronger guarantees to clients and eliminate entire classes of null-checking bugs.

However, be careful with fields that involve expensive lookups or external services. If a non-null field fails, the error bubbles up and nullifies the parent object. For risky fields, nullable is safer:

type User {
  id: ID!
  name: String!
  creditScore: Int  # Nullable: external service might be down
}

Input Types for Mutations

Always use dedicated input types for mutations rather than individual arguments:

# Bad: too many arguments
type Mutation {
  createUser(name: String!, email: String!, role: Role!): User!
}

# Good: single input type
type Mutation {
  createUser(input: CreateUserInput!): CreateUserPayload!
}

input CreateUserInput {
  name: String!
  email: String!
  role: Role!
}

type CreateUserPayload {
  user: User!
}

Input types are easier to extend without breaking changes. Adding a new optional field to CreateUserInput is backward compatible.

Mutation Payloads

Return a payload type from mutations instead of returning the entity directly. This gives you room to add metadata:

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

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

This pattern lets mutations return both the result and any validation errors in a structured way, without relying on GraphQL-level errors for business logic.

Connection Pattern for Lists

For any list that could grow large, use the Connection pattern instead of returning a bare list:

# Bad: no pagination support
type Query {
  users: [User!]!
}

# Good: cursor-based pagination
type Query {
  users(first: Int, after: String, last: Int, before: String): UserConnection!
}

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

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

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

Even if your list is small today, wrapping it in a connection from the start avoids a breaking change when you inevitably need pagination.

Use Enums Over Strings

Whenever a field has a fixed set of values, use an enum instead of a string:

# Bad
type Order {
  status: String!  # "pending"? "Pending"? "PENDING"?
}

# Good
type Order {
  status: OrderStatus!
}

enum OrderStatus {
  PENDING
  PROCESSING
  SHIPPED
  DELIVERED
}

Enums provide type safety, auto-completion in clients, and eliminate typos.

Interfaces and Unions

Use interfaces when types share common fields:

interface Node {
  id: ID!
}

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

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

Use unions for search results or feeds where types do not share structure:

union SearchResult = User | Post | Comment

type Query {
  search(query: String!): [SearchResult!]!
}

Designing for Evolution

Since GraphQL discourages API versioning, design your schema to evolve gracefully:

Adding fields is always safe. Clients that do not request new fields are unaffected.

Deprecate before removing. Use the @deprecated directive to signal that a field will be removed:

type User {
  name: String! @deprecated(reason: "Use firstName and lastName instead")
  firstName: String!
  lastName: String!
}

Never change field types. Changing age: Int to age: String breaks existing clients. Instead, add a new field with the new type.

Never make a nullable field non-null. Clients may not be sending the field. You can safely make a non-null field nullable, but not the reverse.

Avoid Deep Nesting Traps

Design your schema to discourage unnecessarily deep queries:

# Risky: enables very deep nesting
type User {
  friends: [User!]!  # friends of friends of friends...
}

# Better: use a dedicated query with depth control
type Query {
  userFriends(userId: ID!, depth: Int = 1): [User!]!
}

Combine schema design with query depth limiting on the server to prevent abuse.

Custom Scalars

Define custom scalars for domain-specific types:

scalar DateTime
scalar EmailAddress
scalar URL
scalar PositiveInt

Libraries like graphql-scalars provide validated implementations for common scalars. Custom scalars enforce validation at the schema level rather than in every resolver.

Consistent ID Patterns

Use globally unique IDs when possible. This enables client-side caching and the Node interface pattern:

interface Node {
  id: ID!
}

type Query {
  node(id: ID!): Node
}

With globally unique IDs, clients can refetch any object by its ID without knowing the type, which is the foundation of Relay’s caching strategy.

Summary

Good schema design starts with consistent naming, intentional nullability, and proper use of input and payload types. Use the connection pattern for lists, enums for fixed values, and interfaces for shared fields. Design for evolution by adding rather than modifying, deprecating before removing, and never changing field types. These conventions are used by most production GraphQL APIs and will keep your schema maintainable as it grows.