|$ curl https://forge-ai.dev/api/markdown?path=docs/backend/graphql
$cat docs/graphql.md
updated Recently·28 min read·published

GraphQL

BackendGraphQLIntermediate🎯Free Tools
Introduction

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.

Schema Design

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.

schema.graphql
GRAPHQL
1type User {
2 id: ID!
3 email: String!
4 name: String
5 role: Role!
6 createdAt: DateTime!
7 orders: [Order!]!
8}
9
10type Order {
11 id: ID!
12 status: OrderStatus!
13 total: Float!
14 customer: User!
15 items: [OrderItem!]!
16}
17
18type OrderItem {
19 id: ID!
20 productName: String!
21 quantity: Int!
22 unitPrice: Float!
23}
24
25enum Role {
26 ADMIN
27 MEMBER
28 GUEST
29}
30
31enum OrderStatus {
32 PENDING
33 PAID
34 SHIPPED
35 CANCELLED
36}
37
38scalar DateTime
39
40type Query {
41 user(id: ID!): User
42 users(limit: Int = 20, offset: Int = 0): [User!]!
43 order(id: ID!): Order
44}
45
46type Mutation {
47 createUser(input: CreateUserInput!): CreateUserPayload!
48}
49
50input CreateUserInput {
51 email: String!
52 name: String
53 role: Role = MEMBER
54}
55
56type CreateUserPayload {
57 user: User!
58}
59
60schema {
61 query: Query
62 mutation: Mutation
63}

best practice

Use input object types for mutations instead of many scalar arguments. This makes mutations easier to evolve, validate, and document. Return payload objects rather than the mutated entity directly to leave room for errors and metadata.
Resolvers

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.

apollo-resolvers.ts
TypeScript
1import { ApolloServer } from "@apollo/server";
2import { startStandaloneServer } from "@apollo/server/standalone";
3
4const 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
16const 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
29const server = new ApolloServer({
30 typeDefs,
31 resolvers,
32});
33
34const { 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
42console.log(`Server ready at ${url}`);

info

Keep resolvers thin. Move business logic, authorization, and data access into domain services or repositories. Resolvers should orchestrate, not implement.
Mutations

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.

mutation-unions.graphql
GRAPHQL
1type Mutation {
2 createOrder(input: CreateOrderInput!): CreateOrderResult!
3}
4
5union CreateOrderResult = CreateOrderSuccess | ValidationError | NotFoundError | AuthorizationError
6
7type CreateOrderSuccess {
8 order: Order!
9}
10
11type ValidationError {
12 message: String!
13 field: String!
14}
15
16type NotFoundError {
17 message: String!
18}
19
20type AuthorizationError {
21 message: String!
22}

warning

Do not expose database error messages in mutation responses. Return user-friendly error codes and log the full exception server-side. Mutation failures should be actionable for clients and opaque about internal details.
The N+1 Problem

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.

dataloader.ts
TypeScript
1import DataLoader from "dataloader";
2
3async 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
19const orderLoader = new DataLoader(batchGetOrdersByUserIds);
20
21const 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

Deploying GraphQL without DataLoader or an equivalent batching strategy is a recipe for database overload. Test list queries with realistic result sizes and measure query counts before going to production.
Subscriptions

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.

subscriptions.ts
TypeScript
1import { PubSub } from "graphql-subscriptions";
2
3const pubsub = new PubSub();
4
5const typeDefs = `#graphql
6 type Subscription {
7 orderUpdated(orderId: ID!): Order!
8 }
9`;
10
11const 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
21async 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

For production, replace in-memory PubSub with Redis, Kafka, or a managed event bus. In-memory publishers do not work across multiple server instances and will lose messages on restart.
Apollo vs Relay Conventions

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.

ConventionApolloRelay
Object identificationOptional id fieldRequired globally unique id
PaginationAny styleConnection spec with edges/nodes
MutationsFlexibleInput and payload naming rules
Query controlRuntime parsingPersisted queries, allow-list

info

Start with Apollo if your team is new to GraphQL. Consider Relay when you need fine-grained control over client caching, persisted queries, and large-scale React applications.
Security Considerations

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.

graphql-security.ts
TypeScript
1import { ApolloServer } from "@apollo/server";
2import { createComplexityLimitRule } from "graphql-validation-complexity";
3
4const 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

Disable introspection in production unless you are building a public API explorer. Introspection leaks your full schema and can aid attackers in crafting expensive queries.
$Blueprint — Engineering Documentation·Section ID: BE-02·Revision: 1.0