GraphQL Schema Design: Types, Queries, Mutations, and Best Practices
GraphQL shifts the design work from endpoint definition to schema design. The schema is the API — every type, field, query, and mutation is declared in the schema, and the schema governs everything the API can do. Designing a GraphQL schema well requires understanding how types compose, how to express mutations that reflect real domain operations, and where GraphQL’s conventions differ meaningfully from REST patterns that GraphQL developers often import by habit.
The Type System
GraphQL’s type system is the foundation of schema design. Every field on every type has a declared type, and the types form a graph that determines what queries are possible and what shape they return.
Scalar types are the leaves of the graph: String, Int, Float, Boolean, ID (a string identifier), and any custom scalars you define (Date, DateTime, Email, URL, JSON). Custom scalars allow you to express semantic types rather than just syntactic primitives — a DateTime scalar documents that a field contains a timestamp and can be validated accordingly.
Object types are the nodes of the graph. Each field on an object type has a name and a type:
type User {
id: ID!
email: String!
name: String!
createdAt: DateTime!
posts(first: Int, after: String): PostConnection
}
type Post {
id: ID!
title: String!
author: User!
tags: [String!]!
}
The ! suffix marks a field as non-nullable — the server guarantees this field will never be null. Fields without ! may return null, which the client must handle. Nullable fields are appropriate for genuinely optional data; non-null fields communicate guarantees that simplify client code. The convention is to be as non-null as the domain allows — excessive nullability pushes error handling complexity to every client.
Enums express a fixed set of valid values:
enum OrderStatus {
PENDING
PROCESSING
SHIPPED
DELIVERED
CANCELLED
}
Interfaces and unions allow polymorphic types. An interface defines fields that all implementing types must have; a union allows a field to return one of several unrelated types. These are useful for search results, activity feeds, or any context where different resource types appear in the same list.
Query Design
Queries are the read operations. Each query declares what it returns and what arguments it accepts:
type Query {
user(id: ID!): User
users(first: Int, after: String, filter: UserFilter): UserConnection
post(id: ID!): Post
search(query: String!, types: [SearchType!]): [SearchResult!]!
}
Design queries around what clients need to fetch, not around what the database contains. If the most common client operation is loading a user profile with their recent activity, a userProfile query that returns both in a single resolver is better DX than requiring the client to compose a query across multiple separate types.
Pagination for collections follows the Relay Connection specification, which is the de facto standard for paginated GraphQL data:
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
The Connection pattern provides cursor-based pagination with consistent structure across all paginated types. Clients that understand the Connection pattern can paginate any collection that follows it without learning collection-specific conventions. Adopt it from the start; retrofitting it onto existing collections is a breaking change.
Mutation Design
Mutations are the write operations, and their design is where GraphQL schema design diverges most significantly from REST conventions.
The first principle: each mutation should have dedicated input and output types, not reuse query output types for mutation results.
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(input: UpdateUserInput!): UpdateUserPayload!
deleteUser(id: ID!): DeleteUserPayload!
}
input CreateUserInput {
email: String!
name: String!
role: UserRole
}
type CreateUserPayload {
user: User
errors: [UserError!]!
}
The payload type carries both the result (user) and any errors (errors). This is the application-level error pattern in GraphQL — business logic errors (validation failures, conflicts) are returned as data in the payload rather than as GraphQL errors, which are reserved for execution failures. A client can always read the errors field to determine whether a mutation succeeded, regardless of whether the operation touched the user field.
Name mutations as domain operations, not CRUD operations when the domain supports it. createUser is appropriate; archivePost, publishArticle, cancelSubscription, transferOwnership are more expressive than their CRUD equivalents and better reflect the domain semantics.
N+1 and the DataLoader Pattern
GraphQL’s field resolution model naturally produces N+1 query problems. A query for a list of posts with their authors resolves each author independently by default — one database query per post, regardless of whether posts share the same author.
DataLoader solves this by batching and caching field resolution within a single request. Instead of resolving each field immediately, DataLoader collects all requests for a given field during the request, batches them into a single database query at the end of the current execution tick, and distributes results back to each resolver.
const userLoader = new DataLoader(async (userIds) => {
const users = await db.users.findByIds(userIds);
return userIds.map(id => users.find(u => u.id === id));
});
// In the Post.author resolver:
author: (post) => userLoader.load(post.authorId)
DataLoader is not optional for production GraphQL. Without it, any query that traverses relationships will generate N+1 queries. With it, relationship traversal is efficient regardless of how many items are in the list. Build DataLoader into your resolver architecture from the start.
Schema Evolution Without Breaking Changes
GraphQL schemas evolve without versioning, which requires discipline about what constitutes a safe change.
Safe changes: adding new types, adding new fields to existing types, adding new optional arguments to existing fields, adding new values to enums (with caveats — clients that use exhaustive enum switches in statically typed languages may break). These changes do not affect existing queries.
Breaking changes: removing types or fields, changing field types, making optional fields required, removing enum values, changing argument types. Any of these invalidates existing queries. GraphQL provides field-level deprecation (@deprecated(reason: "Use X instead")) to signal that a field will eventually be removed, giving clients time to migrate.
Schema introspection means clients can discover deprecated fields programmatically and monitoring can track usage of deprecated fields across client versions. This is more precise than REST API version deprecation, where the unit of deprecation is the endpoint rather than individual fields.
Documenting the Schema
GraphQL schemas support inline documentation through description strings:
"""
A registered user of the application.
"""
type User {
"""
The user's unique identifier.
"""
id: ID!
"""
The user's email address. Must be unique across all accounts.
"""
email: String!
}
Tools like GraphiQL and Apollo Studio render these descriptions as documentation alongside the schema explorer. A schema with good descriptions is self-documenting in a way that is not possible for REST APIs without a separate documentation tool. The descriptions live in the schema definition and are always in sync with what the API actually exposes — they cannot drift the way external documentation can.
Write descriptions for every type, every field, and every argument. The audience is a developer using GraphiQL at 11pm trying to understand whether they want posts or articles and what the difference is. The description that answers that question clearly is worth the thirty seconds it takes to write.