Importance of Schema Design
Your GraphQL schema is the contract between your API and its consumers. Well-designed schemas are intuitive, flexible, and maintainable. Poor schema design leads to breaking changes, confusing APIs, and frustrated developers. Following best practices ensures your schema scales with your application and remains developer-friendly.
Schema-First vs Code-First Approaches
Two main approaches to building GraphQL schemas:
Schema-First (SDL-First)
Define your schema using GraphQL Schema Definition Language (SDL) first, then implement resolvers:
// schema.graphql
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
}
type Query {
user(id: ID!): User
users: [User!]!
}
// resolvers.js
const resolvers = {
Query: {
user: (parent, { id }, context) => {
return context.db.getUserById(id);
},
users: (parent, args, context) => {
return context.db.getAllUsers();
}
},
User: {
posts: (user, args, context) => {
return context.db.getPostsByUserId(user.id);
}
}
};
Pros: Clear separation, easy to review schema changes, great for teams, language-agnostic
Cons: Schema and code can get out of sync, requires manual validation
Code-First (Resolver-First)
Define schema using TypeScript/JavaScript classes and decorators:
import { ObjectType, Field, ID, Resolver, Query, Arg } from 'type-graphql';
@ObjectType()
class User {
@Field(type => ID)
id: string;
@Field()
name: string;
@Field()
email: string;
@Field(type => [Post])
posts: Post[];
}
@ObjectType()
class Post {
@Field(type => ID)
id: string;
@Field()
title: string;
@Field()
content: string;
@Field(type => User)
author: User;
}
@Resolver(User)
class UserResolver {
@Query(returns => User, { nullable: true })
user(@Arg('id', type => ID) id: string): Promise<User> {
return db.getUserById(id);
}
@Query(returns => [User])
users(): Promise<User[]> {
return db.getAllUsers();
}
}
Pros: Type safety, auto-generated schema, less duplication, IDE support
Cons: Tied to language/framework, harder to review schema independently
Recommendation: Use schema-first for public APIs or large teams where schema review is critical. Use code-first for internal APIs or when type safety is paramount.
Naming Conventions
Consistent naming improves API discoverability and usability:
Types and Fields
// ✅ Good - Clear, consistent naming
type User {
id: ID!
firstName: String! # camelCase for fields
lastName: String!
emailAddress: String!
createdAt: DateTime! # Clear, descriptive names
isActive: Boolean! # Boolean fields start with "is/has/can"
}
type Post {
id: ID!
title: String!
publishedAt: DateTime # Nullable for drafts
author: User! # Singular for single objects
comments: [Comment!]! # Plural for lists
}
// ❌ Bad - Inconsistent, unclear naming
type user { # Types should be PascalCase
ID: ID! # Fields should be camelCase
name: String! # Ambiguous - first name? full name?
email_address: String! # Snake_case not recommended
created: String! # Ambiguous type, unclear format
active: Boolean! # Missing "is" prefix
Author: User! # Field should be camelCase
}
Queries and Mutations
// ✅ Good - Clear action verbs
type Query {
user(id: ID!): User # Singular for single item
users(limit: Int, offset: Int): [User!]! # Plural for lists
searchUsers(query: String!): [User!]! # Descriptive verb + noun
}
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
deleteUser(id: ID!): DeleteUserPayload!
publishPost(id: ID!): PublishPostPayload! # Clear action verb
}
// ❌ Bad - Vague or inconsistent naming
type Query {
getUser(id: ID!): User # "get" is redundant in queries
allUsers: [User!]! # Inconsistent with singular "user"
search(q: String!): [User!]! # Too generic, unclear what's searched
}
type Mutation {
user(input: UserInput!): User! # Unclear if create/update
remove(id: ID!): Boolean! # Too generic, what's being removed?
post(id: ID!): Post! # Unclear action
}
Input Types and Arguments
// ✅ Good - Clear, reusable input types
input CreateUserInput {
firstName: String!
lastName: String!
email: String!
}
input UpdateUserInput {
firstName: String
lastName: String
email: String
}
input PaginationInput {
limit: Int = 10
offset: Int = 0
}
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
}
// ❌ Bad - Repetitive, unclear inputs
type Mutation {
createUser(
firstName: String!,
lastName: String!,
email: String!
): User! # Long argument lists are hard to maintain
updateUser(
id: ID!,
data: UserData! # Generic name, unclear purpose
): User!
}
Response Types and Error Handling
Always return structured payload types instead of direct objects:
// ✅ Good - Structured response with metadata
type CreateUserPayload {
success: Boolean!
message: String
user: User
errors: [UserError!]
}
type UserError {
field: String
message: String!
code: ErrorCode!
}
enum ErrorCode {
VALIDATION_ERROR
DUPLICATE_EMAIL
INVALID_INPUT
UNAUTHORIZED
}
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
}
// Usage allows for partial success and detailed errors
// {
// "data": {
// "createUser": {
// "success": false,
// "message": "Validation failed",
// "user": null,
// "errors": [
// {
// "field": "email",
// "message": "Email already exists",
// "code": "DUPLICATE_EMAIL"
// }
// ]
// }
// }
// }
// ❌ Bad - Direct object return, errors in extensions
type Mutation {
createUser(input: CreateUserInput!): User!
}
// No way to return partial data or structured errors
// Errors must go in top-level errors array
Warning: Throwing errors in resolvers causes the entire operation to fail. Use structured payload types to return partial data and field-specific errors.
Pagination Patterns
Implement consistent pagination across your schema:
// Relay-style cursor pagination (recommended for large datasets)
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
}
// Offset pagination (simpler, good for small datasets)
type Query {
users(limit: Int = 10, offset: Int = 0): UserList!
}
type UserList {
items: [User!]!
totalCount: Int!
limit: Int!
offset: Int!
}
Schema Versioning and Evolution
GraphQL schemas should evolve without breaking changes:
Adding Fields (Safe)
// Version 1
type User {
id: ID!
name: String!
}
// Version 2 - Add new optional field (non-breaking)
type User {
id: ID!
name: String!
email: String # New optional field
}
Deprecating Fields
type User {
id: ID!
name: String! @deprecated(reason: "Use firstName and lastName instead")
firstName: String!
lastName: String!
# Old field still works, but clients are warned
age: Int @deprecated(reason: "Use birthDate for accurate age calculation")
birthDate: DateTime!
}
type Query {
users: [User!]! @deprecated(reason: "Use searchUsers with pagination")
searchUsers(
query: String,
first: Int,
after: String
): UserConnection!
}
Deprecation Strategy:
- Add
@deprecated directive with clear migration path
- Monitor usage of deprecated fields
- Communicate sunset timeline to API consumers
- Remove deprecated fields in next major version
Breaking Changes to Avoid
// ❌ BREAKING: Removing a field
type User {
id: ID!
# name: String! # REMOVED - breaks existing queries
}
// ❌ BREAKING: Changing field type
type User {
id: ID!
age: String! # Changed from Int! to String! - breaks clients
}
// ❌ BREAKING: Making optional field required
type User {
id: ID!
email: String! # Changed from String to String! - breaks mutations
}
// ❌ BREAKING: Changing argument requirements
type Query {
user(id: ID!, email: String!): User # Added required arg - breaks queries
}
// ✅ SAFE: Evolution strategy
type User {
id: ID!
name: String! @deprecated(reason: "Use displayName")
displayName: String! # Add new field first
age: Int @deprecated(reason: "Use birthDate")
birthDate: DateTime # Add optional alternative
}
Modularizing Large Schemas
Split large schemas into logical modules:
// schema/user.graphql
type User {
id: ID!
name: String!
posts: [Post!]!
}
extend type Query {
user(id: ID!): User
users: [User!]!
}
extend type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
}
// schema/post.graphql
type Post {
id: ID!
title: String!
author: User!
}
extend type Query {
post(id: ID!): Post
posts: [Post!]!
}
extend type Mutation {
createPost(input: CreatePostInput!): CreatePostPayload!
}
// schema/index.js
const { mergeTypeDefs } = require('@graphql-tools/merge');
const { loadFilesSync } = require('@graphql-tools/load-files');
const path = require('path');
const typesArray = loadFilesSync(path.join(__dirname, './'), {
extensions: ['graphql']
});
const typeDefs = mergeTypeDefs(typesArray);
module.exports = typeDefs;
Schema Documentation
Document your schema with descriptions:
"""
Represents a user in the system.
Users can create posts, comments, and interact with other users.
"""
type User {
"""Unique identifier for the user"""
id: ID!
"""User's full display name"""
name: String!
"""
User's email address.
This field is only visible to the user themselves and admins.
"""
email: String!
"""
All posts created by this user.
Results are paginated and sorted by creation date (newest first).
"""
posts(first: Int = 10, after: String): PostConnection!
"""Timestamp when the user account was created"""
createdAt: DateTime!
}
"""
Input for creating a new user account.
All fields are required during user registration.
"""
input CreateUserInput {
"""User's full name (2-100 characters)"""
name: String!
"""Valid email address (must be unique)"""
email: String!
"""Password (minimum 8 characters, must include letters and numbers)"""
password: String!
}
Tip: Use triple quotes (""") for multi-line descriptions. Descriptions appear in GraphQL Playground, documentation tools, and IDE autocomplete.
Schema Validation and Linting
Use tools to enforce schema best practices:
// .graphql-config.yml
schema: "src/schema/**/*.graphql"
documents: "src/**/*.{graphql,js,ts}"
extensions:
validation:
rules:
- naming-convention:
types: PascalCase
fields: camelCase
arguments: camelCase
enums: UPPER_CASE
- no-deprecated
- require-description
- no-typename-prefix
// package.json
{
"scripts": {
"lint:schema": "graphql-schema-linter src/schema/**/*.graphql",
"validate:schema": "graphql-inspector validate 'src/schema/**/*.graphql'"
}
}
// Run linting
npm run lint:schema
// Output:
// ✅ All types follow PascalCase
// ❌ Field "User.Email" should be camelCase
// ❌ Type "Post" is missing description
Exercise: Design a complete GraphQL schema for an e-commerce platform following best practices:
- Create types for Product, Order, Customer, and Review with proper naming
- Implement cursor-based pagination for product listings
- Design structured payload types for all mutations
- Add field-level descriptions for all types
- Include a deprecation strategy for renaming "Customer" to "User"
- Split the schema into modular files by domain
- Add comprehensive error codes and error types
Document your design decisions and evolution strategy.