GraphQL Schema Design Best Practices
Schema Naming Conventions
# Types: PascalCase
type User {
id: ID!
email: String!
createdAt: DateTime! # camelCase fields
profilePhoto: URL # descriptive names
}
# Input types: PascalCase + Input suffix
input CreateUserInput {
email: String!
name: String!
role: UserRole = USER # use enums with defaults
}
# Enums: ALL_CAPS values
enum UserRole {
ADMIN
USER
MODERATOR
}
# Mutations: verb + noun
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
deleteUser(id: ID!): DeleteUserPayload!
}
# Payload types: separate from domain types
type CreateUserPayload {
user: User
errors: [UserError!]!
}
type UserError {
field: String
message: String!
code: UserErrorCode!
}
Pagination (Cursor-based)
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
users(
first: Int
after: String
last: Int
before: String
filter: UserFilter
orderBy: UserOrderBy
): UserConnection!
}
N+1 Problem with DataLoader
import DataLoader from 'dataloader';
// Without DataLoader: N+1 queries
// For 100 posts, fetches author 100 separate times
// With DataLoader: batch + cache
const userLoader = new DataLoader<string, User>(async (userIds) => {
const users = await db.query(
'SELECT * FROM users WHERE id = ANY($1)',
[userIds]
);
// Return users in same order as userIds
const userMap = new Map(users.map(u => [u.id, u]));
return userIds.map(id => userMap.get(id) ?? new Error(`User ${id} not found`));
});
// Resolvers
const resolvers = {
Post: {
author: (post: Post) => userLoader.load(post.authorId),
// Single query per batch, not per post
},
};
Error Handling
// Consistent error format
const resolvers = {
Mutation: {
createUser: async (_: unknown, { input }: { input: CreateUserInput }) => {
try {
const validation = validateCreateUser(input);
if (!validation.ok) {
return {
user: null,
errors: validation.errors.map(e => ({
field: e.field,
message: e.message,
code: 'VALIDATION_ERROR',
})),
};
}
const user = await userService.create(input);
return { user, errors: [] };
} catch (err) {
if (err instanceof UniqueConstraintError) {
return {
user: null,
errors: [{ field: 'email', message: 'Email already in use', code: 'DUPLICATE_EMAIL' }],
};
}
throw err; // Let global error handler deal with unexpected errors
}
},
},
};
Subscriptions
type Subscription {
orderUpdated(orderId: ID!): OrderUpdatedPayload!
newMessage(channelId: ID!): Message!
}
const resolvers = {
Subscription: {
orderUpdated: {
subscribe: (_, { orderId }) =>
pubsub.asyncIterator(`ORDER_UPDATED_${orderId}`),
resolve: (payload: OrderUpdatedPayload) => payload,
},
},
Mutation: {
updateOrder: async (_, { id, input }) => {
const order = await orderService.update(id, input);
await pubsub.publish(`ORDER_UPDATED_${id}`, { orderUpdated: order });
return { order, errors: [] };
},
},
};
Schema Evolution
# Deprecate old fields, add new ones
type User {
id: ID!
# Old field
fullName: String @deprecated(reason: "Use 'name' instead")
# New field
name: String!
}
Good GraphQL schema design makes the API self-documenting and easy to evolve.