Backend Developmentintermediate

GraphQL API Design: Building Type-Safe, Scalable APIs

Master GraphQL schema design, resolvers, and best practices for production-grade APIs

PH
PlayHveTech Education Platform
December 4, 2025
50 min read
9.1K views

GraphQL API Design: Building Type-Safe, Scalable APIs

Master GraphQL schema design, resolvers, and best practices for production-grade APIs

Introduction

GraphQL has transformed how we build and consume APIs, offering a more flexible and efficient alternative to traditional REST APIs. With GraphQL, clients can request exactly the data they need, reducing over-fetching and under-fetching while providing strong typing and excellent developer experience.

In this comprehensive tutorial, we'll explore GraphQL from first principles to advanced patterns used in production systems. You'll learn schema design, resolver optimization, authentication, caching strategies, and how to build APIs that scale to millions of requests.

By the end of this guide, you'll have built a complete GraphQL API for a social media platform, complete with real-time subscriptions, advanced filtering, and performance optimization.

Prerequisites

Before diving into GraphQL, you should have:

  • Strong JavaScript/TypeScript fundamentals
  • Understanding of REST API concepts
  • Familiarity with databases (SQL or NoSQL)
  • Node.js and npm installed
  • Basic understanding of async programming

Understanding GraphQL Architecture

GraphQL operates on a fundamentally different model than REST:

REST API:
GET    /users/1
GET    /users/1/posts
GET    /users/1/posts/5/comments

GraphQL API:
POST   /graphql
{
  user(id: 1) {
    name
    posts {
      title
      comments { text }
    }
  }
}

Core Concepts

  1. Schema: Defines types and relationships
  2. Queries: Read operations
  3. Mutations: Write operations
  4. Subscriptions: Real-time updates
  5. Resolvers: Functions that fetch data

Setting Up Your GraphQL Server

Let's build a production-ready GraphQL server with TypeScript and Apollo Server.

Project Setup

mkdir graphql-social-api
cd graphql-social-api
npm init -y

# Install dependencies
npm install @apollo/server graphql graphql-tag
npm install typescript @types/node ts-node-dev --save-dev
npm install prisma @prisma/client
npm install dataloader
npm install bcryptjs jsonwebtoken
npm install @types/bcryptjs @types/jsonwebtoken --save-dev

tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

package.json scripts:

{
  "scripts": {
    "dev": "ts-node-dev --respawn --transpile-only src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js",
    "prisma:generate": "prisma generate",
    "prisma:migrate": "prisma migrate dev"
  }
}

Database Schema with Prisma

prisma/schema.prisma:

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model User {
  id        String   @id @default(uuid())
  email     String   @unique
  username  String   @unique
  password  String
  name      String
  bio       String
  avatar    String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  posts     Post[]
  comments  Comment[]
  likes     Like[]
  followers Follow[]  @relation("UserFollowers")
  following Follow[]  @relation("UserFollowing")
}

model Post {
  id        String   @id @default(uuid())
  title     String
  content   String
  published Boolean  @default(false)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  authorId  String
  author    User     @relation(fields: [authorId], references: [id])
  
  comments  Comment[]
  likes     Like[]
  tags      Tag[]
}

model Comment {
  id        String   @id @default(uuid())
  text      String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  authorId  String
  author    User     @relation(fields: [authorId], references: [id])
  
  postId    String
  post      Post     @relation(fields: [postId], references: [id])
}

model Like {
  id        String   @id @default(uuid())
  createdAt DateTime @default(now())

  userId    String
  user      User     @relation(fields: [userId], references: [id])
  
  postId    String
  post      Post     @relation(fields: [postId], references: [id])

  @@unique([userId, postId])
}

model Follow {
  id          String   @id @default(uuid())
  createdAt   DateTime @default(now())

  followerId  String
  follower    User     @relation("UserFollowers", fields: [followerId], references: [id])
  
  followingId String
  following   User     @relation("UserFollowing", fields: [followingId], references: [id])

  @@unique([followerId, followingId])
}

model Tag {
  id    String @id @default(uuid())
  name  String @unique
  posts Post[]
}

Designing the GraphQL Schema

src/schema/typeDefs.ts:

import { gql } from 'graphql-tag';

export const typeDefs = gql`
  scalar DateTime

  type User {
    id: ID!
    email: String!
    username: String!
    name: String!
    bio: String
    avatar: String
    createdAt: DateTime!
    
    posts(
      first: Int
      after: String
      orderBy: PostOrderBy
    ): PostConnection!
    
    followers(first: Int, after: String): UserConnection!
    following(first: Int, after: String): UserConnection!
    followersCount: Int!
    followingCount: Int!
    
    isFollowing: Boolean!
  }

  type Post {
    id: ID!
    title: String!
    content: String!
    published: Boolean!
    createdAt: DateTime!
    updatedAt: DateTime!
    
    author: User!
    comments(first: Int, after: String): CommentConnection!
    likes(first: Int, after: String): LikeConnection!
    tags: [Tag!]!
    
    likesCount: Int!
    commentsCount: Int!
    isLiked: Boolean!
  }

  type Comment {
    id: ID!
    text: String!
    createdAt: DateTime!
    updatedAt: DateTime!
    
    author: User!
    post: Post!
  }

  type Like {
    id: ID!
    createdAt: DateTime!
    user: User!
    post: Post!
  }

  type Tag {
    id: ID!
    name: String!
    posts(first: Int, after: String): PostConnection!
  }

  # Pagination types
  type PageInfo {
    hasNextPage: Boolean!
    hasPreviousPage: Boolean!
    startCursor: String
    endCursor: String
  }

  type UserEdge {
    node: User!
    cursor: String!
  }

  type UserConnection {
    edges: [UserEdge!]!
    pageInfo: PageInfo!
    totalCount: Int!
  }

  type PostEdge {
    node: Post!
    cursor: String!
  }

  type PostConnection {
    edges: [PostEdge!]!
    pageInfo: PageInfo!
    totalCount: Int!
  }

  type CommentEdge {
    node: Comment!
    cursor: String!
  }

  type CommentConnection {
    edges: [CommentEdge!]!
    pageInfo: PageInfo!
    totalCount: Int!
  }

  type LikeEdge {
    node: Like!
    cursor: String!
  }

  type LikeConnection {
    edges: [LikeEdge!]!
    pageInfo: PageInfo!
    totalCount: Int!
  }

  # Input types
  input CreateUserInput {
    email: String!
    username: String!
    password: String!
    name: String!
    bio: String
  }

  input UpdateUserInput {
    email: String
    username: String
    name: String
    bio: String
    avatar: String
  }

  input CreatePostInput {
    title: String!
    content: String!
    published: Boolean
    tags: [String!]
  }

  input UpdatePostInput {
    title: String
    content: String
    published: Boolean
    tags: [String!]
  }

  input CreateCommentInput {
    postId: ID!
    text: String!
  }

  # Enums
  enum PostOrderBy {
    CREATED_AT_ASC
    CREATED_AT_DESC
    LIKES_COUNT_DESC
    COMMENTS_COUNT_DESC
  }

  # Authentication
  type AuthPayload {
    token: String!
    user: User!
  }

  # Queries
  type Query {
    me: User
    user(id: ID, username: String): User
    users(
      first: Int
      after: String
      searchQuery: String
    ): UserConnection!
    
    post(id: ID!): Post
    posts(
      first: Int
      after: String
      authorId: ID
      tag: String
      published: Boolean
      orderBy: PostOrderBy
    ): PostConnection!
    
    feed(first: Int, after: String): PostConnection!
    
    searchPosts(query: String!, first: Int, after: String): PostConnection!
  }

  # Mutations
  type Mutation {
    # Auth
    signup(input: CreateUserInput!): AuthPayload!
    login(email: String!, password: String!): AuthPayload!
    
    # User
    updateUser(input: UpdateUserInput!): User!
    deleteUser: Boolean!
    
    # Post
    createPost(input: CreatePostInput!): Post!
    updatePost(id: ID!, input: UpdatePostInput!): Post!
    deletePost(id: ID!): Boolean!
    
    # Comment
    createComment(input: CreateCommentInput!): Comment!
    deleteComment(id: ID!): Boolean!
    
    # Like
    likePost(postId: ID!): Like!
    unlikePost(postId: ID!): Boolean!
    
    # Follow
    followUser(userId: ID!): User!
    unfollowUser(userId: ID!): Boolean!
  }

  # Subscriptions
  type Subscription {
    postCreated(authorId: ID): Post!
    commentAdded(postId: ID!): Comment!
    postLiked(postId: ID!): Like!
  }
`;

Implementing Resolvers

src/resolvers/index.ts:

import { PrismaClient } from '@prisma/client';
import { GraphQLError } from 'graphql';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import DataLoader from 'dataloader';

const prisma = new PrismaClient();

// Context type
export interface Context {
  prisma: PrismaClient;
  userId: string;
  loaders: {
    userLoader: DataLoader<string, any>;
    postLoader: DataLoader<string, any>;
    likesCountLoader: DataLoader<string, number>;
    commentsCountLoader: DataLoader<string, number>;
  };
}

// DataLoader for batching
function createUserLoader() {
  return new DataLoader<string, any>(async (ids) => {
    const users = await prisma.user.findMany({
      where: { id: { in: [...ids] } },
    });
    const userMap = new Map(users.map(user => [user.id, user]));
    return ids.map(id => userMap.get(id));
  });
}

function createPostLoader() {
  return new DataLoader<string, any>(async (ids) => {
    const posts = await prisma.post.findMany({
      where: { id: { in: [...ids] } },
    });
    const postMap = new Map(posts.map(post => [post.id, post]));
    return ids.map(id => postMap.get(id));
  });
}

function createLikesCountLoader() {
  return new DataLoader<string, number>(async (postIds) => {
    const counts = await prisma.like.groupBy({
      by: ['postId'],
      where: { postId: { in: [...postIds] } },
      _count: true,
    });
    const countMap = new Map(counts.map(c => [c.postId, c._count]));
    return postIds.map(id => countMap.get(id) || 0);
  });
}

function createCommentsCountLoader() {
  return new DataLoader<string, number>(async (postIds) => {
    const counts = await prisma.comment.groupBy({
      by: ['postId'],
      where: { postId: { in: [...postIds] } },
      _count: true,
    });
    const countMap = new Map(counts.map(c => [c.postId, c._count]));
    return postIds.map(id => countMap.get(id) || 0);
  });
}

// Helper functions
function requireAuth(context: Context) {
  if (!context.userId) {
    throw new GraphQLError('Authentication required', {
      extensions: { code: 'UNAUTHENTICATED' },
    });
  }
  return context.userId;
}

function generateToken(userId: string): string {
  return jwt.sign({ userId }, process.env.JWT_SECRET || 'your-secret-key', {
    expiresIn: '7d',
  });
}

// Pagination helper
function encodeCursor(id: string): string {
  return Buffer.from(id).toString('base64');
}

function decodeCursor(cursor: string): string {
  return Buffer.from(cursor, 'base64').toString('utf-8');
}

export const resolvers = {
  Query: {
    me: async (_: any, __: any, context: Context) => {
      const userId = requireAuth(context);
      return context.loaders.userLoader.load(userId);
    },

    user: async (_: any, args: { id: string; username: string }, context: Context) => {
      if (args.id) {
        return context.loaders.userLoader.load(args.id);
      }
      if (args.username) {
        return prisma.user.findUnique({ where: { username: args.username } });
      }
      throw new GraphQLError('Must provide id or username');
    },

    users: async (
      _: any,
      args: { first: number; after: string; searchQuery: string },
      context: Context
    ) => {
      const limit = Math.min(args.first || 20, 100);
      const cursor = args.after  decodeCursor(args.after) : undefined;

      const where = args.searchQuery
         {
            OR: [
              { name: { contains: args.searchQuery, mode: 'insensitive' as const } },
              { username: { contains: args.searchQuery, mode: 'insensitive' as const } },
            ],
          }
        : {};

      const users = await prisma.user.findMany({
        where,
        take: limit + 1,
        ...(cursor && { cursor: { id: cursor }, skip: 1 }),
        orderBy: { createdAt: 'desc' },
      });

      const hasNextPage = users.length > limit;
      const edges = users.slice(0, limit).map(user => ({
        node: user,
        cursor: encodeCursor(user.id),
      }));

      return {
        edges,
        pageInfo: {
          hasNextPage,
          hasPreviousPage: !!cursor,
          startCursor: edges[0].cursor,
          endCursor: edges[edges.length - 1].cursor,
        },
        totalCount: await prisma.user.count({ where }),
      };
    },

    post: async (_: any, args: { id: string }, context: Context) => {
      return context.loaders.postLoader.load(args.id);
    },

    posts: async (
      _: any,
      args: {
        first: number;
        after: string;
        authorId: string;
        tag: string;
        published: boolean;
        orderBy: string;
      },
      context: Context
    ) => {
      const limit = Math.min(args.first || 20, 100);
      const cursor = args.after  decodeCursor(args.after) : undefined;

      const where: any = {};
      if (args.authorId) where.authorId = args.authorId;
      if (args.published !== undefined) where.published = args.published;
      if (args.tag) {
        where.tags = { some: { name: args.tag } };
      }

      const orderBy = (() => {
        switch (args.orderBy) {
          case 'CREATED_AT_ASC': return { createdAt: 'asc' as const };
          case 'CREATED_AT_DESC': return { createdAt: 'desc' as const };
          default: return { createdAt: 'desc' as const };
        }
      })();

      const posts = await prisma.post.findMany({
        where,
        take: limit + 1,
        ...(cursor && { cursor: { id: cursor }, skip: 1 }),
        orderBy,
      });

      const hasNextPage = posts.length > limit;
      const edges = posts.slice(0, limit).map(post => ({
        node: post,
        cursor: encodeCursor(post.id),
      }));

      return {
        edges,
        pageInfo: {
          hasNextPage,
          hasPreviousPage: !!cursor,
          startCursor: edges[0].cursor,
          endCursor: edges[edges.length - 1].cursor,
        },
        totalCount: await prisma.post.count({ where }),
      };
    },

    feed: async (
      _: any,
      args: { first: number; after: string },
      context: Context
    ) => {
      const userId = requireAuth(context);
      const limit = Math.min(args.first || 20, 100);
      const cursor = args.after  decodeCursor(args.after) : undefined;

      // Get followed user IDs
      const following = await prisma.follow.findMany({
        where: { followerId: userId },
        select: { followingId: true },
      });

      const followingIds = following.map(f => f.followingId);

      const posts = await prisma.post.findMany({
        where: {
          authorId: { in: followingIds },
          published: true,
        },
        take: limit + 1,
        ...(cursor && { cursor: { id: cursor }, skip: 1 }),
        orderBy: { createdAt: 'desc' },
      });

      const hasNextPage = posts.length > limit;
      const edges = posts.slice(0, limit).map(post => ({
        node: post,
        cursor: encodeCursor(post.id),
      }));

      return {
        edges,
        pageInfo: {
          hasNextPage,
          hasPreviousPage: !!cursor,
          startCursor: edges[0].cursor,
          endCursor: edges[edges.length - 1].cursor,
        },
        totalCount: await prisma.post.count({
          where: { authorId: { in: followingIds }, published: true },
        }),
      };
    },
  },

  Mutation: {
    signup: async (_: any, args: { input: any }) => {
      const { email, username, password, name, bio } = args.input;

      // Check if user exists
      const existing = await prisma.user.findFirst({
        where: { OR: [{ email }, { username }] },
      });

      if (existing) {
        throw new GraphQLError('User already exists', {
          extensions: { code: 'BAD_USER_INPUT' },
        });
      }

      // Hash password
      const hashedPassword = await bcrypt.hash(password, 10);

      // Create user
      const user = await prisma.user.create({
        data: {
          email,
          username,
          password: hashedPassword,
          name,
          bio,
        },
      });

      const token = generateToken(user.id);

      return { token, user };
    },

    login: async (_: any, args: { email: string; password: string }) => {
      const user = await prisma.user.findUnique({
        where: { email: args.email },
      });

      if (!user) {
        throw new GraphQLError('Invalid credentials', {
          extensions: { code: 'BAD_USER_INPUT' },
        });
      }

      const valid = await bcrypt.compare(args.password, user.password);

      if (!valid) {
        throw new GraphQLError('Invalid credentials', {
          extensions: { code: 'BAD_USER_INPUT' },
        });
      }

      const token = generateToken(user.id);

      return { token, user };
    },

    createPost: async (_: any, args: { input: any }, context: Context) => {
      const userId = requireAuth(context);
      const { title, content, published, tags } = args.input;

      const post = await prisma.post.create({
        data: {
          title,
          content,
          published: published  false,
          authorId: userId,
          ...(tags && {
            tags: {
              connectOrCreate: tags.map((tag: string) => ({
                where: { name: tag },
                create: { name: tag },
              })),
            },
          }),
        },
        include: { tags: true },
      });

      return post;
    },

    likePost: async (_: any, args: { postId: string }, context: Context) => {
      const userId = requireAuth(context);

      const like = await prisma.like.create({
        data: {
          userId,
          postId: args.postId,
        },
      });

      return like;
    },

    followUser: async (_: any, args: { userId: string }, context: Context) => {
      const followerId = requireAuth(context);

      if (followerId === args.userId) {
        throw new GraphQLError('Cannot follow yourself');
      }

      await prisma.follow.create({
        data: {
          followerId,
          followingId: args.userId,
        },
      });

      return context.loaders.userLoader.load(args.userId);
    },
  },

  User: {
    posts: async (parent: any, args: any, context: Context) => {
      // Implementation similar to Query.posts
      return { edges: [], pageInfo: {}, totalCount: 0 };
    },
    
    followersCount: async (parent: any, _: any, context: Context) => {
      return prisma.follow.count({ where: { followingId: parent.id } });
    },
    
    followingCount: async (parent: any, _: any, context: Context) => {
      return prisma.follow.count({ where: { followerId: parent.id } });
    },
    
    isFollowing: async (parent: any, _: any, context: Context) => {
      if (!context.userId) return false;
      
      const follow = await prisma.follow.findUnique({
        where: {
          followerId_followingId: {
            followerId: context.userId,
            followingId: parent.id,
          },
        },
      });
      
      return !!follow;
    },
  },

  Post: {
    author: async (parent: any, _: any, context: Context) => {
      return context.loaders.userLoader.load(parent.authorId);
    },
    
    likesCount: async (parent: any, _: any, context: Context) => {
      return context.loaders.likesCountLoader.load(parent.id);
    },
    
    commentsCount: async (parent: any, _: any, context: Context) => {
      return context.loaders.commentsCountLoader.load(parent.id);
    },
    
    isLiked: async (parent: any, _: any, context: Context) => {
      if (!context.userId) return false;
      
      const like = await prisma.like.findUnique({
        where: {
          userId_postId: {
            userId: context.userId,
            postId: parent.id,
          },
        },
      });
      
      return !!like;
    },
  },
};

Server Setup

src/index.ts:

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { PrismaClient } from '@prisma/client';
import jwt from 'jsonwebtoken';
import { typeDefs } from './schema/typeDefs';
import { resolvers, Context } from './resolvers';
import DataLoader from 'dataloader';

const prisma = new PrismaClient();

// DataLoader factory
function createLoaders() {
  return {
    userLoader: new DataLoader(async (ids: readonly string[]) => {
      const users = await prisma.user.findMany({
        where: { id: { in: [...ids] } },
      });
      const userMap = new Map(users.map(u => [u.id, u]));
      return ids.map(id => userMap.get(id));
    }),
    postLoader: new DataLoader(async (ids: readonly string[]) => {
      const posts = await prisma.post.findMany({
        where: { id: { in: [...ids] } },
      });
      const postMap = new Map(posts.map(p => [p.id, p]));
      return ids.map(id => postMap.get(id));
    }),
    likesCountLoader: new DataLoader(async (postIds: readonly string[]) => {
      const counts = await prisma.like.groupBy({
        by: ['postId'],
        where: { postId: { in: [...postIds] } },
        _count: true,
      });
      const countMap = new Map(counts.map(c => [c.postId, c._count]));
      return postIds.map(id => countMap.get(id) || 0);
    }),
    commentsCountLoader: new DataLoader(async (postIds: readonly string[]) => {
      const counts = await prisma.comment.groupBy({
        by: ['postId'],
        where: { postId: { in: [...postIds] } },
        _count: true,
      });
      const countMap = new Map(counts.map(c => [c.postId, c._count]));
      return postIds.map(id => countMap.get(id) || 0);
    }),
  };
}

const server = new ApolloServer<Context>({
  typeDefs,
  resolvers,
});

async function startServer() {
  const { url } = await startStandaloneServer(server, {
    listen: { port: 4000 },
    context: async ({ req }): Promise<Context> => {
      const token = req.headers.authorization.replace('Bearer ', '');
      let userId: string | undefined;

      if (token) {
        try {
          const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key') as { userId: string };
          userId = decoded.userId;
        } catch (err) {
          // Invalid token
        }
      }

      return {
        prisma,
        userId,
        loaders: createLoaders(),
      };
    },
  });

  console.log(`馃殌 Server ready at ${url}`);
}

startServer().catch(console.error);

Best Practices and Optimization

1. N+1 Query Problem

Use DataLoader to batch database queries:

// Without DataLoader - N+1 queries
posts.forEach(post => {
  const author = await prisma.user.findUnique({ where: { id: post.authorId } });
});

// With DataLoader - 1 query
posts.forEach(post => {
  const author = await context.loaders.userLoader.load(post.authorId);
});

2. Depth Limiting

Prevent deeply nested queries:

import { createComplexityLimitRule } from 'graphql-validation-complexity';

const server = new ApolloServer({
  // ...
  validationRules: [createComplexityLimitRule(1000)],
});

3. Caching

Implement response caching:

import responseCachePlugin from '@apollo/server-plugin-response-cache';

const server = new ApolloServer({
  // ...
  plugins: [
    responseCachePlugin({
      sessionId: (requestContext) => {
        return requestContext.request.http.headers.get('session-id') || null;
      },
    }),
  ],
});

Conclusion

GraphQL provides a powerful, flexible approach to API design that scales from simple applications to complex enterprise systems. By understanding schema design, resolver optimization, and best practices, you can build APIs that are both developer-friendly and performant.

Key Takeaways

  • Schema-first design ensures clear API contracts
  • DataLoader eliminates N+1 query problems
  • Cursor-based pagination scales better than offset pagination
  • Type safety catches errors at compile time
  • Field-level resolvers enable flexible data fetching

The patterns demonstrated here are used in production GraphQL APIs serving millions of requests daily. Apply these principles to build robust, scalable APIs.

Next Steps

  1. Add real-time features with GraphQL subscriptions
  2. Implement federation for microservices architecture
  3. Add monitoring with Apollo Studio or similar tools
  4. Explore code generation with GraphQL Code Generator

Additional Resources


This tutorial is part of the PlayHve API Development series. Master modern API patterns with our comprehensive guides.

PH

Written by PlayHve

Tech Education Platform

Your ultimate destination for cutting-edge technology tutorials. Learn AI, Web3, modern web development, and creative coding.