Back to Blog
GraphQLAPISchema DesignBackend

GraphQL Schema Design Best Practices

Design effective GraphQL APIs. From schema structure to naming conventions to error handling patterns.

B
Bootspring Team
Engineering
July 20, 2023
6 min read

A well-designed GraphQL schema is intuitive, performant, and evolvable. Here's how to design schemas that scale with your application.

Schema Structure#

1# Clear type definitions 2type User { 3 id: ID! 4 email: String! 5 name: String! 6 avatar: String 7 role: UserRole! 8 createdAt: DateTime! 9 updatedAt: DateTime! 10 11 # Relationships 12 posts(first: Int, after: String): PostConnection! 13 comments(first: Int, after: String): CommentConnection! 14} 15 16enum UserRole { 17 ADMIN 18 USER 19 GUEST 20} 21 22# Use scalar types for complex values 23scalar DateTime 24scalar Email 25scalar URL

Naming Conventions#

1# Types: PascalCase 2type User { ... } 3type BlogPost { ... } 4 5# Fields: camelCase 6type User { 7 firstName: String! 8 lastName: String! 9 emailAddress: Email! 10} 11 12# Enums: SCREAMING_SNAKE_CASE 13enum OrderStatus { 14 PENDING 15 PROCESSING 16 SHIPPED 17 DELIVERED 18 CANCELLED 19} 20 21# Inputs: TypeNameInput 22input CreateUserInput { 23 email: Email! 24 name: String! 25 password: String! 26} 27 28input UpdateUserInput { 29 name: String 30 avatar: URL 31} 32 33# Connections: TypeNameConnection 34type UserConnection { 35 edges: [UserEdge!]! 36 pageInfo: PageInfo! 37 totalCount: Int! 38}

Queries Design#

1type Query { 2 # Single resource by ID 3 user(id: ID!): User 4 post(id: ID!): Post 5 6 # Single resource by unique field 7 userByEmail(email: Email!): User 8 9 # Collections with pagination 10 users( 11 first: Int 12 after: String 13 filter: UserFilter 14 orderBy: UserOrderBy 15 ): UserConnection! 16 17 posts( 18 first: Int 19 after: String 20 filter: PostFilter 21 ): PostConnection! 22 23 # Viewer pattern for current user 24 viewer: User 25} 26 27# Filter inputs 28input UserFilter { 29 role: UserRole 30 createdAfter: DateTime 31 search: String 32} 33 34input UserOrderBy { 35 field: UserOrderField! 36 direction: OrderDirection! 37} 38 39enum UserOrderField { 40 CREATED_AT 41 NAME 42 EMAIL 43} 44 45enum OrderDirection { 46 ASC 47 DESC 48}

Mutations Design#

1type Mutation { 2 # Action-oriented naming 3 createUser(input: CreateUserInput!): CreateUserPayload! 4 updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload! 5 deleteUser(id: ID!): DeleteUserPayload! 6 7 # Specific actions 8 publishPost(id: ID!): PublishPostPayload! 9 archivePost(id: ID!): ArchivePostPayload! 10 11 # Batch operations 12 deleteUsers(ids: [ID!]!): DeleteUsersPayload! 13} 14 15# Consistent payload structure 16type CreateUserPayload { 17 user: User 18 errors: [Error!]! 19} 20 21type UpdateUserPayload { 22 user: User 23 errors: [Error!]! 24} 25 26type DeleteUserPayload { 27 deletedId: ID 28 errors: [Error!]! 29} 30 31# Error type 32type Error { 33 field: String 34 message: String! 35 code: ErrorCode! 36} 37 38enum ErrorCode { 39 VALIDATION_ERROR 40 NOT_FOUND 41 UNAUTHORIZED 42 FORBIDDEN 43 INTERNAL_ERROR 44}

Relay Connection Spec#

1# Pagination interface 2interface Connection { 3 edges: [Edge!]! 4 pageInfo: PageInfo! 5} 6 7interface Edge { 8 cursor: String! 9 node: Node! 10} 11 12type PageInfo { 13 hasNextPage: Boolean! 14 hasPreviousPage: Boolean! 15 startCursor: String 16 endCursor: String 17} 18 19# Implementation 20type PostConnection implements Connection { 21 edges: [PostEdge!]! 22 pageInfo: PageInfo! 23 totalCount: Int! 24} 25 26type PostEdge implements Edge { 27 cursor: String! 28 node: Post! 29} 30 31# Usage in resolvers 32const resolvers = { 33 Query: { 34 posts: async (_, { first, after, filter }) => { 35 const limit = Math.min(first || 20, 100); 36 const cursor = decodeCursor(after); 37 38 const posts = await prisma.post.findMany({ 39 where: buildFilter(filter, cursor), 40 take: limit + 1, 41 orderBy: { createdAt: 'desc' }, 42 }); 43 44 const hasNextPage = posts.length > limit; 45 const edges = posts.slice(0, limit).map((post) => ({ 46 node: post, 47 cursor: encodeCursor(post.id), 48 })); 49 50 return { 51 edges, 52 pageInfo: { 53 hasNextPage, 54 hasPreviousPage: !!after, 55 startCursor: edges[0]?.cursor, 56 endCursor: edges[edges.length - 1]?.cursor, 57 }, 58 totalCount: await prisma.post.count({ where: filter }), 59 }; 60 }, 61 }, 62};

Interfaces and Unions#

1# Interface for shared fields 2interface Node { 3 id: ID! 4} 5 6interface Timestamped { 7 createdAt: DateTime! 8 updatedAt: DateTime! 9} 10 11type User implements Node & Timestamped { 12 id: ID! 13 createdAt: DateTime! 14 updatedAt: DateTime! 15 name: String! 16} 17 18type Post implements Node & Timestamped { 19 id: ID! 20 createdAt: DateTime! 21 updatedAt: DateTime! 22 title: String! 23} 24 25# Union for polymorphic types 26union SearchResult = User | Post | Comment 27 28type Query { 29 search(query: String!): [SearchResult!]! 30} 31 32# Resolver 33const resolvers = { 34 SearchResult: { 35 __resolveType(obj) { 36 if (obj.email) return 'User'; 37 if (obj.title) return 'Post'; 38 if (obj.content && obj.postId) return 'Comment'; 39 return null; 40 }, 41 }, 42};

Subscriptions#

1type Subscription { 2 # Real-time updates 3 postCreated: Post! 4 postUpdated(id: ID!): Post! 5 6 # Filtered subscriptions 7 commentAdded(postId: ID!): Comment! 8 9 # User-specific 10 notificationReceived: Notification! 11} 12 13# Resolver with filtering 14const resolvers = { 15 Subscription: { 16 commentAdded: { 17 subscribe: withFilter( 18 () => pubsub.asyncIterator(['COMMENT_ADDED']), 19 (payload, variables) => { 20 return payload.commentAdded.postId === variables.postId; 21 } 22 ), 23 }, 24 }, 25};

Custom Directives#

1# Schema directives 2directive @auth(requires: Role = USER) on FIELD_DEFINITION 3directive @deprecated(reason: String) on FIELD_DEFINITION 4directive @rateLimit(max: Int!, window: Int!) on FIELD_DEFINITION 5 6type Query { 7 users: [User!]! @auth(requires: ADMIN) 8 publicPosts: [Post!]! @rateLimit(max: 100, window: 60) 9} 10 11type User { 12 email: String! @auth(requires: ADMIN) 13 oldField: String @deprecated(reason: "Use newField instead") 14}

Schema Evolution#

1# Deprecate instead of remove 2type User { 3 # Old field - deprecated 4 fullName: String @deprecated(reason: "Use firstName and lastName") 5 6 # New fields 7 firstName: String! 8 lastName: String! 9} 10 11# Add optional fields 12type Post { 13 id: ID! 14 title: String! 15 content: String! 16 17 # New optional field - non-breaking 18 summary: String 19} 20 21# Extend types 22extend type User { 23 preferences: UserPreferences 24} 25 26type UserPreferences { 27 theme: Theme 28 notifications: Boolean 29}

Error Handling#

1// Error types 2class GraphQLError extends Error { 3 constructor( 4 message: string, 5 public code: string, 6 public field?: string 7 ) { 8 super(message); 9 } 10} 11 12// Resolver with error handling 13const resolvers = { 14 Mutation: { 15 createUser: async (_, { input }) => { 16 const errors: Error[] = []; 17 18 // Validation 19 if (!isValidEmail(input.email)) { 20 errors.push({ 21 field: 'email', 22 message: 'Invalid email format', 23 code: 'VALIDATION_ERROR', 24 }); 25 } 26 27 if (input.password.length < 8) { 28 errors.push({ 29 field: 'password', 30 message: 'Password must be at least 8 characters', 31 code: 'VALIDATION_ERROR', 32 }); 33 } 34 35 if (errors.length > 0) { 36 return { user: null, errors }; 37 } 38 39 // Check uniqueness 40 const existing = await prisma.user.findUnique({ 41 where: { email: input.email }, 42 }); 43 44 if (existing) { 45 return { 46 user: null, 47 errors: [{ 48 field: 'email', 49 message: 'Email already in use', 50 code: 'VALIDATION_ERROR', 51 }], 52 }; 53 } 54 55 const user = await prisma.user.create({ data: input }); 56 return { user, errors: [] }; 57 }, 58 }, 59};

Best Practices#

Schema Design: ✓ Use consistent naming conventions ✓ Design for client needs ✓ Keep types focused ✓ Use connections for lists Evolution: ✓ Deprecate before removing ✓ Add fields as optional ✓ Version breaking changes ✓ Document changes Performance: ✓ Avoid deeply nested queries ✓ Implement DataLoader ✓ Set query complexity limits ✓ Use persisted queries

Conclusion#

Good GraphQL schema design requires thinking about client needs, evolution strategy, and performance. Follow consistent conventions, use the Relay connection spec for pagination, and design mutations with proper error handling.

Your schema is your API contract—invest time in getting it right.

Share this article

Help spread the word about Bootspring