GraphQL API Design and Implementation
Design a GraphQL API that solves REST API over-fetching/under-fetching problems, and build a production-level API with Apollo Server + TypeScript
Problem
Required Tools
An open-source framework for building GraphQL servers. Integrates with Express and provides a plugin system and performance monitoring.
Used for type-safe schema definitions and resolver implementations. Types can be auto-generated with graphql-codegen.
A batching/caching utility to solve the N+1 query problem. Batches individual DB queries from repeated resolver calls into a single query.
An interactive IDE for testing and exploring GraphQL APIs. Provides schema documentation and autocompletion.
A TypeScript-based ORM. Enables type-safe database queries within GraphQL resolvers.
Solution Steps
Project Setup and Apollo Server Configuration
Install Apollo Server 4 with required dependencies and set up the basic server. Integrating as Express middleware allows coexistence with existing REST endpoints, which is beneficial for gradual migration. Using TypeScript catches type mismatches between schema and resolvers at compile time.
# Initialize project and install dependencies
mkdir graphql-api && cd graphql-api
npm init -y
npm install @apollo/server graphql express cors
npm install -D typescript @types/node @types/express @types/cors ts-node nodemon
# Generate tsconfig.json
npx tsc --init --rootDir src --outDir dist --esModuleInterop --resolveJsonModule --strict
# src/index.ts - Apollo Server + Express integration
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import express from 'express';
import cors from 'cors';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';
import { createContext } from './context';
const app = express();
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: process.env.NODE_ENV !== 'production',
});
await server.start();
app.use(
'/graphql',
cors(),
express.json(),
expressMiddleware(server, {
context: createContext,
})
);
app.listen(4000, () => {
console.log('GraphQL API running at http://localhost:4000/graphql');
});Design the GraphQL Schema (Type Definitions)
The GraphQL schema is the contract of your API. Define Types, Queries, and Mutations based on your domain model. Structuring mutation arguments with Input types and constraining finite choices with Enums greatly improves API usability. Carefully designing relationships (1:N, M:N) during schema design is essential for preventing N+1 problems.
# src/schema.ts
export const typeDefs = `#graphql
enum Role {
ADMIN
USER
EDITOR
}
type User {
id: ID!
email: String!
name: String!
role: Role!
posts: [Post!]!
createdAt: String!
}
type Post {
id: ID!
title: String!
content: String!
published: Boolean!
author: User!
tags: [Tag!]!
createdAt: String!
updatedAt: String!
}
type Tag {
id: ID!
name: String!
posts: [Post!]!
}
input CreatePostInput {
title: String!
content: String!
published: Boolean = false
tagIds: [ID!]
}
input UpdatePostInput {
title: String
content: String
published: Boolean
tagIds: [ID!]
}
input PostFilterInput {
published: Boolean
authorId: ID
tagId: ID
search: String
}
type Query {
me: User
user(id: ID!): User
users(role: Role): [User!]!
post(id: ID!): Post
posts(filter: PostFilterInput, limit: Int = 20, offset: Int = 0): [Post!]!
tags: [Tag!]!
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
login(email: String!, password: String!): AuthPayload!
}
type AuthPayload {
token: String!
user: User!
}
`;Implement Resolvers and Context Setup
Resolvers define the logic for fetching data for each field in the schema. Through Context, you pass authentication info, DB connections, and DataLoader instances to all resolvers. Understanding the resolver chain pattern, where parent resolver data is used by child resolvers, is fundamental.
// src/context.ts
import { PrismaClient } from '@prisma/client';
import { verifyToken } from './auth';
import { createLoaders } from './loaders';
const prisma = new PrismaClient();
export interface Context {
prisma: PrismaClient;
user: { id: string; role: string } | null;
loaders: ReturnType<typeof createLoaders>;
}
export async function createContext({ req }: { req: any }): Promise<Context> {
const token = req.headers.authorization?.replace('Bearer ', '');
const user = token ? verifyToken(token) : null;
return { prisma, user, loaders: createLoaders(prisma) };
}
// src/resolvers.ts
import { Context } from './context';
import { GraphQLError } from 'graphql';
export const resolvers = {
Query: {
me: (_: any, __: any, ctx: Context) => {
if (!ctx.user) throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
});
return ctx.prisma.user.findUnique({ where: { id: ctx.user.id } });
},
posts: (_: any, args: any, ctx: Context) => {
const { filter, limit, offset } = args;
return ctx.prisma.post.findMany({
where: {
...(filter?.published !== undefined && { published: filter.published }),
...(filter?.authorId && { authorId: filter.authorId }),
...(filter?.search && {
OR: [
{ title: { contains: filter.search, mode: 'insensitive' } },
{ content: { contains: filter.search, mode: 'insensitive' } },
],
}),
},
take: limit,
skip: offset,
orderBy: { createdAt: 'desc' },
});
},
},
// Resolver chain: executes when the author field of Post type is requested
Post: {
author: (post: any, _: any, ctx: Context) =>
ctx.loaders.userLoader.load(post.authorId),
tags: (post: any, _: any, ctx: Context) =>
ctx.loaders.postTagsLoader.load(post.id),
},
User: {
posts: (user: any, _: any, ctx: Context) =>
ctx.prisma.post.findMany({ where: { authorId: user.id } }),
},
};Solve N+1 Query Problem with DataLoader
In GraphQL's resolver chain, if an individual DB query is executed for each item, the N+1 problem occurs. DataLoader batches individual requests that occur within a single event loop tick into a single query. You must create new DataLoader instances per request to ensure cache isolation.
// src/loaders.ts
import DataLoader from 'dataloader';
import { PrismaClient } from '@prisma/client';
export function createLoaders(prisma: PrismaClient) {
// User batch loader: [id1, id2, id3] -> resolved with 1 query
const userLoader = new DataLoader(async (ids: readonly string[]) => {
const users = await prisma.user.findMany({
where: { id: { in: [...ids] } },
});
// DataLoader must return results in the same order as inputs
const userMap = new Map(users.map(u => [u.id, u]));
return ids.map(id => userMap.get(id) || null);
});
// Post Tags batch loader (M:N relationship)
const postTagsLoader = new DataLoader(async (postIds: readonly string[]) => {
const relations = await prisma.postTag.findMany({
where: { postId: { in: [...postIds] } },
include: { tag: true },
});
const tagMap = new Map<string, any[]>();
for (const rel of relations) {
const existing = tagMap.get(rel.postId) || [];
existing.push(rel.tag);
tagMap.set(rel.postId, existing);
}
return postIds.map(id => tagMap.get(id) || []);
});
return { userLoader, postTagsLoader };
}
// N+1 problem comparison:
// Without DataLoader (querying 20 posts):
// SELECT * FROM posts LIMIT 20; -- 1 query
// SELECT * FROM users WHERE id = ?; -- 20 queries (author for each post)
// Total: 21 queries
// With DataLoader:
// SELECT * FROM posts LIMIT 20; -- 1 query
// SELECT * FROM users WHERE id IN (?, ?, ...); -- 1 query (batched)
// Total: 2 queriesImplement Authentication and Authorization
In GraphQL, authentication is handled through context, and authorization through resolvers or directives. JWT tokens are received via HTTP headers, user information is injected into the context, and permissions are verified in resolvers. Creating custom directives enables declarative access control at the schema level.
// src/auth.ts
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
import { GraphQLError } from 'graphql';
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
export function verifyToken(token: string) {
try {
return jwt.verify(token, JWT_SECRET) as { id: string; role: string };
} catch {
return null;
}
}
// Authentication required helper
export function requireAuth(ctx: any) {
if (!ctx.user) {
throw new GraphQLError('Authentication required', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
return ctx.user;
}
// Role-based authorization helper
export function requireRole(ctx: any, roles: string[]) {
const user = requireAuth(ctx);
if (!roles.includes(user.role)) {
throw new GraphQLError('Insufficient permissions', {
extensions: { code: 'FORBIDDEN' },
});
}
return user;
}
// Applying authorization in Mutation resolvers
const mutationResolvers = {
createPost: async (_: any, { input }: any, ctx: any) => {
const user = requireAuth(ctx); // Login required
return ctx.prisma.post.create({
data: { ...input, authorId: user.id },
});
},
deletePost: async (_: any, { id }: any, ctx: any) => {
const user = requireAuth(ctx);
const post = await ctx.prisma.post.findUnique({ where: { id } });
// Only author or admin can delete
if (post.authorId !== user.id && user.role !== 'ADMIN') {
throw new GraphQLError('No permission to delete', {
extensions: { code: 'FORBIDDEN' },
});
}
await ctx.prisma.post.delete({ where: { id } });
return true;
},
};Error Handling and Input Validation
GraphQL communicates errors through an errors array instead of HTTP status codes. Custom error codes and structured error responses enable clients to handle errors programmatically. Input validation can be done directly in resolvers or using dedicated validation libraries.
// src/errors.ts - Custom error classes
import { GraphQLError } from 'graphql';
export class ValidationError extends GraphQLError {
constructor(message: string, field: string) {
super(message, {
extensions: {
code: 'VALIDATION_ERROR',
field,
},
});
}
}
export class NotFoundError extends GraphQLError {
constructor(resource: string, id: string) {
super(`${resource} not found: ${id}`, {
extensions: {
code: 'NOT_FOUND',
resource,
id,
},
});
}
}
// Input validation example
function validateCreatePost(input: any) {
const errors: string[] = [];
if (!input.title || input.title.trim().length < 2) {
errors.push('Title must be at least 2 characters');
}
if (!input.content || input.content.trim().length < 10) {
errors.push('Content must be at least 10 characters');
}
if (errors.length > 0) {
throw new GraphQLError('Invalid input', {
extensions: { code: 'VALIDATION_ERROR', errors },
});
}
}
// Apollo Server error formatting plugin
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (formattedError, error) => {
// Hide internal error messages in production
if (process.env.NODE_ENV === 'production' &&
!formattedError.extensions?.code) {
return { message: 'Internal server error', extensions: { code: 'INTERNAL_ERROR' } };
}
return formattedError;
},
});Performance Optimization and Query Complexity Limits
To protect the server from malicious or excessively deep queries, you must limit query depth and complexity. Query complexity analysis assigns a cost to each field and rejects execution when the total cost exceeds a threshold. Leveraging response caching and persisted queries significantly improves performance for repeated requests.
// Query depth/complexity limits
npm install graphql-depth-limit graphql-query-complexity
// src/index.ts
import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
depthLimit(7), // Max query depth of 7
createComplexityLimitRule(1000, {
scalarCost: 1,
objectCost: 5,
listFactor: 10,
}),
],
// Automatic persisted queries (APQ)
persistedQueries: {
ttl: 900, // 15-minute cache
},
});
// Cursor-based pagination (performance optimization for large datasets)
const paginatedPosts = async (_: any, args: any, ctx: any) => {
const { first = 20, after } = args;
const cursor = after ? { id: after } : undefined;
const posts = await ctx.prisma.post.findMany({
take: first + 1, // +1 to check if next page exists
...(cursor && { skip: 1, cursor }),
orderBy: { createdAt: 'desc' },
});
const hasNextPage = posts.length > first;
const edges = posts.slice(0, first).map(post => ({
cursor: post.id,
node: post,
}));
return {
edges,
pageInfo: {
hasNextPage,
endCursor: edges.length > 0 ? edges[edges.length - 1].cursor : null,
},
};
};Core Code
Core GraphQL API structure integrating Apollo Server + DataLoader + authentication context. Shows the relationships between schema, resolvers, DataLoader, and context.
// GraphQL API Core Structure (Apollo Server + TypeScript + DataLoader)
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import DataLoader from 'dataloader';
import express from 'express';
// 1. Schema definition
const typeDefs = `#graphql
type User { id: ID!; name: String!; posts: [Post!]! }
type Post { id: ID!; title: String!; author: User! }
type Query { posts(limit: Int = 20, offset: Int = 0): [Post!]! }
type Mutation { createPost(title: String!, content: String!): Post! }
`;
// 2. DataLoader (N+1 solution)
function createLoaders(db: any) {
return {
userLoader: new DataLoader(async (ids: readonly string[]) => {
const users = await db.user.findMany({ where: { id: { in: [...ids] } } });
const map = new Map(users.map((u: any) => [u.id, u]));
return ids.map(id => map.get(id));
}),
};
}
// 3. Resolvers (data logic)
const resolvers = {
Query: {
posts: (_: any, args: any, ctx: any) =>
ctx.db.post.findMany({ take: args.limit, skip: args.offset }),
},
Post: {
author: (post: any, _: any, ctx: any) => ctx.loaders.userLoader.load(post.authorId),
},
Mutation: {
createPost: (_: any, args: any, ctx: any) => {
if (!ctx.user) throw new Error('Unauthorized');
return ctx.db.post.create({ data: { ...args, authorId: ctx.user.id } });
},
},
};
// 4. Start server
const server = new ApolloServer({ typeDefs, resolvers });
await server.start();
const app = express();
app.use('/graphql', express.json(), expressMiddleware(server, {
context: async ({ req }) => ({
db: prisma,
user: verifyToken(req.headers.authorization),
loaders: createLoaders(prisma),
}),
}));
app.listen(4000);Common Mistakes
Creating DataLoader instances globally, causing shared cache between requests
DataLoader must create new instances per request. If created globally, data cached in User A's request could be returned to User B. Always call createLoaders() in the context function for each request.
Deploying to production without query depth/complexity limits, leaving the server vulnerable to DoS attacks
Use graphql-depth-limit to limit nesting depth and graphql-query-complexity to limit query complexity. In production, also disable introspection and use persisted queries to only allow approved queries.
DB errors exposed directly to clients without try-catch in resolvers
Use Apollo Server's formatError option to hide internal error messages in production. Throwing custom errors (GraphQLError) from resolvers gives you control over error codes and messages.