GraphQL Schema Design: Types, Resolvers, Mutations and Best Practices
GraphQL gives clients the power to ask for exactly what they need β no more over-fetching entire objects, no more under-fetching and making multiple requests. But that power comes with design responsibility. A poorly designed schema is just as painful as a poorly designed REST API. This guide walks through schema design, resolvers, and the patterns that make GraphQL production-ready.
Why GraphQL?
In REST, the server decides what each endpoint returns. In GraphQL, the client decides:
graphql# Client asks for exactly what it needs query { user(id: "42") { name email posts(limit: 5) { title publishedAt } } }
One request, exactly the right data, no unused fields. This is especially powerful for mobile clients where bandwidth matters.
Schema Definition Language (SDL)
The schema is the contract between client and server:
graphql# Scalar types type User { id: ID! name: String! email: String! age: Int score: Float isActive: Boolean! createdAt: String! posts: [Post!]! profile: Profile } # ! means non-null # [Post!]! means non-null list of non-null Posts type Post { id: ID! title: String! content: String! author: User! tags: [String!]! publishedAt: String } type Profile { bio: String avatarUrl: String website: String } # Entry points type Query { user(id: ID!): User users(limit: Int = 10, offset: Int = 0): [User!]! post(id: ID!): Post posts(authorId: ID, tag: String): [Post!]! }
Queries and Resolvers
Each field in your schema needs a resolver β a function that fetches the data.
javascript// Node.js with Apollo Server import { ApolloServer } from "@apollo/server"; import { startStandaloneServer } from "@apollo/server/standalone"; const typeDefs = `#graphql type User { id: ID! name: String! email: String! posts: [Post!]! } type Post { id: ID! title: String! author: User! } type Query { user(id: ID!): User users: [User!]! } `; const resolvers = { Query: { user: async (parent, { id }, context) => { return context.db.users.findById(id); }, users: async (parent, args, context) => { return context.db.users.findAll(); }, }, // Field resolvers -- called when the field is requested User: { posts: async (parent, args, context) => { // parent is the User object return context.db.posts.findByAuthorId(parent.id); }, }, Post: { author: async (parent, args, context) => { return context.db.users.findById(parent.authorId); }, }, };
Mutations
Mutations modify data. Always return the modified resource:
graphqltype Mutation { createUser(input: CreateUserInput!): CreateUserPayload! updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload! deleteUser(id: ID!): DeleteUserPayload! createPost(input: CreatePostInput!): CreatePostPayload! } input CreateUserInput { name: String! email: String! password: String! } type CreateUserPayload { user: User errors: [UserError!]! } type UserError { field: String message: String! }
javascriptconst resolvers = { Mutation: { createUser: async (parent, { input }, context) => { try { const existing = await context.db.users.findByEmail(input.email); if (existing) { return { user: null, errors: [{ field: "email", message: "Email already in use" }], }; } const user = await context.db.users.create({ name: input.name, email: input.email, passwordHash: await bcrypt.hash(input.password, 12), }); return { user, errors: [] }; } catch (err) { return { user: null, errors: [{ message: err.message }] }; } }, }, };
Using a payload type with errors (instead of throwing) makes error handling consistent and client-friendly.
The N+1 Problem and DataLoader
The most important performance issue in GraphQL. When fetching a list of posts, a naive implementation makes one DB query per post to get its author:
codeGET /graphql { posts { title author { name } } } SELECT * FROM posts -- 1 query SELECT * FROM users WHERE id = 1 -- N queries SELECT * FROM users WHERE id = 2 SELECT * FROM users WHERE id = 3 ...
Fix with DataLoader β it batches and deduplicates all loads within a single tick:
javascriptimport DataLoader from "dataloader"; // Create a loader that batches user lookups const userLoader = new DataLoader(async (userIds) => { const users = await db.users.findByIds(userIds); // DataLoader requires results in same order as keys return userIds.map(id => users.find(u => u.id === id) || null); }); // In resolver context -- new loader per request (don't share between requests) function createContext(req) { return { db, loaders: { user: new DataLoader(async (ids) => { const users = await db.users.findByIds(ids); return ids.map(id => users.find(u => u.id === id)); }), }, }; } // Post resolver using DataLoader const resolvers = { Post: { author: (parent, args, context) => { return context.loaders.user.load(parent.authorId); -- DataLoader batches all concurrent .load() calls into one DB query }, }, };
100 posts β 1 query for all users instead of 100 queries. Always use DataLoader for any field that loads related entities.
Authentication and Authorization
javascriptconst server = new ApolloServer({ typeDefs, resolvers, }); const { url } = await startStandaloneServer(server, { context: async ({ req }) => { const token = req.headers.authorization?.replace("Bearer ", ""); let currentUser = null; if (token) { try { const payload = jwt.verify(token, process.env.JWT_SECRET); currentUser = await db.users.findById(payload.userId); } catch { -- Invalid token -- currentUser stays null } } return { db, currentUser, loaders: createLoaders() }; }, }); -- In resolvers, check context.currentUser const resolvers = { Query: { me: (parent, args, context) => { if (!context.currentUser) throw new GraphQLError("Not authenticated", { extensions: { code: "UNAUTHENTICATED" }, }); return context.currentUser; }, }, };
Subscriptions
Real-time updates via WebSocket:
graphqltype Subscription { messageAdded(channelId: ID!): Message! orderStatusChanged(orderId: ID!): Order! }
javascriptimport { PubSub } from "graphql-subscriptions"; const pubsub = new PubSub(); const resolvers = { Mutation: { sendMessage: async (parent, { input }, context) => { const message = await db.messages.create(input); pubsub.publish(`MESSAGE_ADDED_${input.channelId}`, { messageAdded: message, }); return message; }, }, Subscription: { messageAdded: { subscribe: (parent, { channelId }) => pubsub.asyncIterator(`MESSAGE_ADDED_${channelId}`), }, }, };
Schema Best Practices
Use input types for mutations β groups related arguments, easier to extend:
graphql-- Bad createUser(name: String!, email: String!, password: String!): User -- Good createUser(input: CreateUserInput!): CreateUserPayload!
Pagination with connections β the Relay cursor pagination spec is the standard:
graphqltype UserConnection { edges: [UserEdge!]! pageInfo: PageInfo! totalCount: Int! } type UserEdge { node: User! cursor: String! } type PageInfo { hasNextPage: Boolean! endCursor: String } type Query { users(first: Int, after: String): UserConnection! }
Never return raw database IDs β use opaque global IDs:
graphql-- Opaque ID encodes type + database ID -- "VXNlcjoxMjM=" = base64("User:123")
Common Interview Questions
Q: What is the difference between a query and a mutation in GraphQL?
Queries are read-only operations β they fetch data and have no side effects. Mutations modify data (create, update, delete). While you technically could use a query to modify data, the distinction is a contract: clients and servers agree that queries are safe to retry and cache, mutations are not.
Q: How does GraphQL solve the over-fetching and under-fetching problems of REST?
In REST, endpoints return fixed shapes β you often get more data than needed (over-fetching) or need multiple requests to get related data (under-fetching). In GraphQL, clients specify exactly which fields they want in a single request β no wasted bandwidth, no extra round trips.
Q: What is the N+1 problem in GraphQL and how do you fix it?
When resolving a list of items, naive implementations make one database query per item to load related data β N items means N+1 total queries. DataLoader fixes this by batching all loads that happen within the same event loop tick into a single database query, then distributing the results back to each resolver.
Practice on Froquiz
GraphQL and API design are tested in frontend and backend developer interviews. Explore our REST API and backend quizzes on Froquiz β and check back as we add GraphQL-specific content.
Summary
- GraphQL lets clients request exactly what they need β no over or under-fetching
- The schema is the contract: define types, queries, mutations, and subscriptions in SDL
- Resolvers are functions that fetch data for each field β they compose hierarchically
- Mutation payload types with an
errorsarray are more client-friendly than throwing errors - DataLoader batches and deduplicates N+1 database calls into single queries
- Authentication goes in context β every resolver can access
context.currentUser - Use input types for mutations, connection types for pagination, opaque IDs for entity references