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 URLNaming 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.