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
- Schema: Defines types and relationships
- Queries: Read operations
- Mutations: Write operations
- Subscriptions: Real-time updates
- 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
- Add real-time features with GraphQL subscriptions
- Implement federation for microservices architecture
- Add monitoring with Apollo Studio or similar tools
- 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.