liminfo

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

GraphQL API designGraphQL schemaApollo ServerGraphQL resolverN+1 problem solutionGraphQL authenticationTypeScript GraphQLDataLoader

Problem

The existing REST API requires the frontend to call multiple endpoints to render a single screen (under-fetching), and returns unnecessary fields (over-fetching). Mobile and web clients need the same data in different shapes, causing endpoints to continuously proliferate, and API documentation management has become difficult. You need to migrate to GraphQL to build a flexible API where clients request only the data they need, while ensuring type safety and auto-generated documentation.

Required Tools

Apollo Server

An open-source framework for building GraphQL servers. Integrates with Express and provides a plugin system and performance monitoring.

TypeScript

Used for type-safe schema definitions and resolver implementations. Types can be auto-generated with graphql-codegen.

DataLoader

A batching/caching utility to solve the N+1 query problem. Batches individual DB queries from repeated resolver calls into a single query.

GraphQL Playground

An interactive IDE for testing and exploring GraphQL APIs. Provides schema documentation and autocompletion.

Prisma

A TypeScript-based ORM. Enables type-safe database queries within GraphQL resolvers.

Solution Steps

1

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');
});
2

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!
  }
`;
3

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 } }),
  },
};
4

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 queries
5

Implement 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;
  },
};
6

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;
  },
});
7

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.

Related liminfo Services