GraphQL
GraphQL is a query language and runtime for APIs developed by Meta. Unlike REST, where the server defines the shape of each endpoint response, GraphQL lets clients request exactly the fields they need. A single GraphQL endpoint can replace dozens of REST endpoints, reducing over-fetching and under-fetching.
The central artifact in GraphQL is the schema. The schema defines the types, queries, mutations, and subscriptions available to clients. Servers implement resolvers that fetch data for each field. The GraphQL engine walks the query tree, calls resolvers, and assembles the response.
This guide teaches production GraphQL: schema design, resolver patterns, mutations, subscriptions, solving the N+1 problem, and the practical differences between Apollo and Relay conventions.
A GraphQL schema is a contract between client and server. Design it around the domain, not the database. Types should represent concepts your users understand, such as User, Order, or Comment, rather than tables or ORM entities.
The schema is strongly typed. Every field has a type, and types can be required using an exclamation mark. Non-nullability communicates guarantees to clients. Be conservative about making fields non-null; if a value can legitimately be missing, mark it nullable.
| 1 | type User { |
| 2 | id: ID! |
| 3 | email: String! |
| 4 | name: String |
| 5 | role: Role! |
| 6 | createdAt: DateTime! |
| 7 | orders: [Order!]! |
| 8 | } |
| 9 | |
| 10 | type Order { |
| 11 | id: ID! |
| 12 | status: OrderStatus! |
| 13 | total: Float! |
| 14 | customer: User! |
| 15 | items: [OrderItem!]! |
| 16 | } |
| 17 | |
| 18 | type OrderItem { |
| 19 | id: ID! |
| 20 | productName: String! |
| 21 | quantity: Int! |
| 22 | unitPrice: Float! |
| 23 | } |
| 24 | |
| 25 | enum Role { |
| 26 | ADMIN |
| 27 | MEMBER |
| 28 | GUEST |
| 29 | } |
| 30 | |
| 31 | enum OrderStatus { |
| 32 | PENDING |
| 33 | PAID |
| 34 | SHIPPED |
| 35 | CANCELLED |
| 36 | } |
| 37 | |
| 38 | scalar DateTime |
| 39 | |
| 40 | type Query { |
| 41 | user(id: ID!): User |
| 42 | users(limit: Int = 20, offset: Int = 0): [User!]! |
| 43 | order(id: ID!): Order |
| 44 | } |
| 45 | |
| 46 | type Mutation { |
| 47 | createUser(input: CreateUserInput!): CreateUserPayload! |
| 48 | } |
| 49 | |
| 50 | input CreateUserInput { |
| 51 | email: String! |
| 52 | name: String |
| 53 | role: Role = MEMBER |
| 54 | } |
| 55 | |
| 56 | type CreateUserPayload { |
| 57 | user: User! |
| 58 | } |
| 59 | |
| 60 | schema { |
| 61 | query: Query |
| 62 | mutation: Mutation |
| 63 | } |
best practice
Resolvers are functions that produce the value for a single field. They receive four arguments: the parent object, the field arguments, the request context, and information about the query execution. Resolvers can return values, promises, or arrays.
Each field has a default resolver that looks up a property with the same name on the parent. You only need custom resolvers when the field name differs from the underlying data source or when additional logic is required.
| 1 | import { ApolloServer } from "@apollo/server"; |
| 2 | import { startStandaloneServer } from "@apollo/server/standalone"; |
| 3 | |
| 4 | const typeDefs = `#graphql |
| 5 | type Query { |
| 6 | user(id: ID!): User |
| 7 | } |
| 8 | |
| 9 | type User { |
| 10 | id: ID! |
| 11 | email: String! |
| 12 | displayName: String! |
| 13 | } |
| 14 | `; |
| 15 | |
| 16 | const resolvers = { |
| 17 | Query: { |
| 18 | user: async (_parent, args, context) => { |
| 19 | return context.userRepository.findById(args.id); |
| 20 | }, |
| 21 | }, |
| 22 | User: { |
| 23 | displayName: (parent) => { |
| 24 | return parent.name || parent.email.split("@")[0]; |
| 25 | }, |
| 26 | }, |
| 27 | }; |
| 28 | |
| 29 | const server = new ApolloServer({ |
| 30 | typeDefs, |
| 31 | resolvers, |
| 32 | }); |
| 33 | |
| 34 | const { url } = await startStandaloneServer(server, { |
| 35 | listen: { port: 4000 }, |
| 36 | context: async ({ req }) => ({ |
| 37 | userRepository: createUserRepository(), |
| 38 | currentUser: await authenticate(req.headers.authorization), |
| 39 | }), |
| 40 | }); |
| 41 | |
| 42 | console.log(`Server ready at ${url}`); |
info
Mutations are the write operations of GraphQL. Unlike queries, which are read-only, mutations change server state. By convention, mutations are named with verbs and should be executed serially by the server, even if multiple mutations appear in one request.
Always validate inputs and handle errors explicitly. Return union types or payload objects with an errors field when a mutation can fail for predictable reasons. This lets clients distinguish between validation errors, not-found errors, and authorization failures.
| 1 | type Mutation { |
| 2 | createOrder(input: CreateOrderInput!): CreateOrderResult! |
| 3 | } |
| 4 | |
| 5 | union CreateOrderResult = CreateOrderSuccess | ValidationError | NotFoundError | AuthorizationError |
| 6 | |
| 7 | type CreateOrderSuccess { |
| 8 | order: Order! |
| 9 | } |
| 10 | |
| 11 | type ValidationError { |
| 12 | message: String! |
| 13 | field: String! |
| 14 | } |
| 15 | |
| 16 | type NotFoundError { |
| 17 | message: String! |
| 18 | } |
| 19 | |
| 20 | type AuthorizationError { |
| 21 | message: String! |
| 22 | } |
warning
The N+1 problem is the most common performance issue in GraphQL. If a query asks for a list of users and each user's orders, a naive resolver implementation queries the database once for users and then once per user for orders. With one hundred users, that is one hundred and one queries.
The standard solutions are DataLoader and query analysis. DataLoader batches concurrent requests for the same key and caches results within a single request. Query analysis limits complexity by calculating the cost of a query before executing it.
| 1 | import DataLoader from "dataloader"; |
| 2 | |
| 3 | async function batchGetOrdersByUserIds(userIds: string[]) { |
| 4 | const orders = await db.order.findMany({ |
| 5 | where: { userId: { in: userIds } }, |
| 6 | }); |
| 7 | |
| 8 | const ordersByUser = new Map<string, typeof orders>(); |
| 9 | for (const order of orders) { |
| 10 | if (!ordersByUser.has(order.userId)) { |
| 11 | ordersByUser.set(order.userId, []); |
| 12 | } |
| 13 | ordersByUser.get(order.userId)!.push(order); |
| 14 | } |
| 15 | |
| 16 | return userIds.map((id) => ordersByUser.get(id) || []); |
| 17 | } |
| 18 | |
| 19 | const orderLoader = new DataLoader(batchGetOrdersByUserIds); |
| 20 | |
| 21 | const resolvers = { |
| 22 | User: { |
| 23 | orders: (parent, _args, context) => { |
| 24 | return context.orderLoader.load(parent.id); |
| 25 | }, |
| 26 | }, |
| 27 | Query: { |
| 28 | users: async () => db.user.findMany({ take: 100 }), |
| 29 | }, |
| 30 | }; |
danger
Subscriptions enable real-time updates over a persistent connection, usually WebSockets or server-sent events. They are appropriate for live dashboards, chat, notifications, and collaborative editing. Keep subscriptions focused on small, targeted events rather than broadcasting large payloads.
| 1 | import { PubSub } from "graphql-subscriptions"; |
| 2 | |
| 3 | const pubsub = new PubSub(); |
| 4 | |
| 5 | const typeDefs = `#graphql |
| 6 | type Subscription { |
| 7 | orderUpdated(orderId: ID!): Order! |
| 8 | } |
| 9 | `; |
| 10 | |
| 11 | const resolvers = { |
| 12 | Subscription: { |
| 13 | orderUpdated: { |
| 14 | subscribe: (_parent, args) => |
| 15 | pubsub.asyncIterableIterator(`ORDER_UPDATED_${args.orderId}`), |
| 16 | }, |
| 17 | }, |
| 18 | }; |
| 19 | |
| 20 | // Somewhere in your order service |
| 21 | async function updateOrderStatus(orderId: string, status: string) { |
| 22 | const order = await orderService.update(orderId, { status }); |
| 23 | await pubsub.publish(`ORDER_UPDATED_${orderId}`, { orderUpdated: order }); |
| 24 | return order; |
| 25 | } |
note
Apollo and Relay are the two dominant GraphQL client and server ecosystems. Apollo prioritizes flexibility and ease of adoption. Relay enforces stricter conventions that enable powerful optimizations such as persisted queries and compiler-level validation.
| Convention | Apollo | Relay |
|---|---|---|
| Object identification | Optional id field | Required globally unique id |
| Pagination | Any style | Connection spec with edges/nodes |
| Mutations | Flexible | Input and payload naming rules |
| Query control | Runtime parsing | Persisted queries, allow-list |
info
GraphQL's flexibility creates unique security challenges. Clients can craft expensive queries, nested deeply, or request enormous lists. Protect your server with query depth limits, complexity scoring, pagination defaults, and timeouts.
Authorization must happen at the field level. A user may be allowed to see a post but not its draft comments. Implement authorization checks inside resolvers or use directive-based guards. Never rely on the schema alone to hide sensitive data.
| 1 | import { ApolloServer } from "@apollo/server"; |
| 2 | import { createComplexityLimitRule } from "graphql-validation-complexity"; |
| 3 | |
| 4 | const server = new ApolloServer({ |
| 5 | typeDefs, |
| 6 | resolvers, |
| 7 | validationRules: [ |
| 8 | createComplexityLimitRule(1000, { |
| 9 | onComplete: (complexity) => { |
| 10 | console.log(`Query complexity: ${complexity}`); |
| 11 | }, |
| 12 | }), |
| 13 | ], |
| 14 | plugins: [ |
| 15 | { |
| 16 | async requestDidStart() { |
| 17 | return { |
| 18 | async didResolveOperation({ request, document }) { |
| 19 | if (request.operationName === "IntrospectionQuery") { |
| 20 | // disable introspection in production |
| 21 | } |
| 22 | }, |
| 23 | }; |
| 24 | }, |
| 25 | }, |
| 26 | ], |
| 27 | }); |
warning