Graphql Api Design and Best Practices — Complete Guide
In this tutorial, you'll learn about GraphQL Api Design and Best Practices. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
GraphQL is a query language and runtime for APIs that lets clients request exactly the data they need, reducing overfetching and underfetching while providing a strongly typed schema for self-documenting APIs.
What You'll Learn
You will learn GraphQL schema design, query and mutation patterns, resolver implementation, batching with DataLoader, subscription setup, and production security practices for GraphQL APIs.
Why GraphQL Matters
GraphQL solves three major problems of REST APIs: overfetching (getting too much data), underfetching (needing multiple requests), and versioning (evolving the schema without breaking clients). Companies like GitHub, Shopify, and Meta use GraphQL in production, serving billions of requests daily.
Real-World Use
DodaTech uses GraphQL for the Doda Browser dashboard where different views need different user data, DodaZIP uses GraphQL for its analytics dashboard with customizable data grids, and Durga Antivirus Pro uses GraphQL for threat intelligence queries where analysts need flexible data combinations.
GraphQL Api Design Learning Path
flowchart LR
A[REST API Basics] --> B[GraphQL Concepts]
B --> C[Schema & Types]
C --> D[Queries & Mutations]
D --> E[Resolvers]
E --> F[DataLoader]
F --> G[Subscriptions & Security]
B:::current
classDef current fill:#f90,color:#fff,stroke:#333,stroke-width:2px
Prerequisites
Understand RESTful Api Design Best Practices and API Development Concepts. Familiarity with JSON Data Format and a programming language like JavaScript Basics or Python Basics is required.
What is GraphQL?
GraphQL was developed by Facebook in 2012 and open-sourced in 2015. It is not a database technology. It is a middle layer between clients and data sources.
Key concepts:
- Schema — Defines what data is available and its types
- Query — Read data (equivalent to REST GET)
- Mutation — Write data (equivalent to REST POST, PUT, PATCH, DELETE)
- Subscription — Real-time data (equivalent to WebSocket)
- Resolver — Function that fetches data for a field
- Type — Defines the shape of an object
Schema Design
The Schema Definition Language (SDL)
# Define types
type User {
id: ID!
name: String!
email: String!
role: Role!
posts: [Post!]!
createdAt: String!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
comments: [Comment!]!
createdAt: String!
}
type Comment {
id: ID!
text: String!
author: User!
post: Post!
createdAt: String!
}
enum Role {
ADMIN
MEMBER
VIEWER
}
# Entry points
type Query {
users(page: Int, limit: Int): [User!]!
user(id: ID!): User
posts(authorId: ID): [Post!]!
post(id: ID!): Post
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: UpdateUserInput!): User!
deleteUser(id: ID!): Boolean!
createPost(input: CreatePostInput!): Post!
}
type Subscription {
postCreated: Post!
commentAdded(postId: ID!): Comment!
}
input CreateUserInput {
name: String!
email: String!
password: String!
role: Role!
}
input UpdateUserInput {
name: String
email: String
role: Role
}
Design Principles
Prefer nullable fields — Not every field needs to be required. Use
!only when the value is guaranteed.Use custom types over scalars — Instead of
email: String, create anEmailscalar with validation.Design for client needs — The schema should reflect how clients consume data, not how it is stored in the database.
Limit depth — Allow reasonable nesting but prevent circular queries that cause performance issues.
Implementing a GraphQL Server with Apollo Server
Step 1: Setup
npm init -y
npm install @apollo/server graphql
Step 2: Define Schema
const { ApolloServer } = require("@apollo/server");
const { startStandaloneServer } = require("@apollo/server/standalone");
const typeDefs = `#graphql
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
}
type Query {
users: [User!]!
user(id: ID!): User
posts: [Post!]!
}
type Mutation {
createUser(name: String!, email: String!): User!
createPost(title: String!, content: String!, authorId: ID!): Post!
}
`;
Step 3: Implement Resolvers
const users = [
{ id: "1", name: "Alice", email: "alice"@example".com" },
{ id: "2", name: "Bob", email: "bob"@example".com" }
];
const posts = [
{ id: "1", title: "GraphQL Guide", content: "Content...", authorId: "1" },
{ id: "2", title: "REST vs GraphQL", content: "Content...", authorId: "1" }
];
const resolvers = {
Query: {
users: () => users,
user: (_, { id }) => users.find(u => u.id === id),
posts: () => posts
},
Mutation: {
createUser: (_, { name, email }) => {
const user = { id: String(users.length + 1), name, email };
users.push(user);
return user;
},
createPost: (_, { title, content, authorId }) => {
const post = { id: String(posts.length + 1), title, content, authorId };
posts.push(post);
return post;
}
},
User: {
posts: (parent) => posts.filter(p => p.authorId === parent.id)
},
Post: {
author: (parent) => users.find(u => u.id === parent.authorId)
}
};
const server = new ApolloServer({ typeDefs, resolvers });
startStandaloneServer(server, { listen: { port: 4000 } })
.then(({ url }) => console.log(`Server ready at ${url}`));
Expected output:
Server ready at http://localhost:4000/
Step 4: Execute Queries
Open Apollo Sandbox at HTTP://localhost:4000 and run:
query GetUsers {
users {
id
name
email
posts {
title
}
}
}
Expected result:
{
"data": {
"users": [
{
"id": "1",
"name": "Alice",
"email": "alice"@example".com",
"posts": [
{ "title": "Graphql Guide" },
{ "title": "REST vs Graphql" }
]
},
{
"id": "2",
"name": "Bob",
"email": "bob@example.com",
"posts": []
}
]
}
}
Solving the N+1 Problem with DataLoader
The N+1 Problem occurs when GraphQL makes one query for the parent and N queries for each child.
// Without DataLoader: N+1 Problem
// Getting 100 users and their posts makes 1 + 100 = 101 queries
User: {
posts: (parent) => db.query("SELECT * FROM posts WHERE author_id = $1", [parent.id])
}
Solution: DataLoader
const DataLoader = require("dataloader");
const batchPostsByAuthorIds = async (authorIds) => {
const posts = await db.query(
"SELECT * FROM posts WHERE author_id = ANY($1)",
[authorIds]
);
return authorIds.map(id => posts.filter(p => p.author_id === id));
};
const postLoader = new DataLoader(batchPostsByAuthorIds);
const resolvers = {
User: {
posts: (parent) => postLoader.load(parent.id)
}
};
DataLoader batches multiple load calls into a single query and caches results within a single request.
Mutations and Input Types
mutation CreateNewUser {
createUser(input: { name: "Charlie", email: "charlie@example.com", role: MEMBER }) {
id
name
email
role
}
}
Mutation Response Pattern
Return the created or modified object along with any status information:
type MutationResponse {
success: Boolean!
message: String
user: User
}
type Mutation {
createUser(input: CreateUserInput!): MutationResponse!
}
Subscriptions for Real-Time Data
const { PubSub } = require("Graphql-subscriptions");
const pubsub = new PubSub();
const typeDefs = `#Graphql
type Subscription {
postCreated: Post!
}
`;
const resolvers = {
Subscription: {
postCreated: {
subscribe: () => pubsub.asyncIterator(["POST_CREATED"])
}
}
};
// In createPost mutation resolver
const resolver = {
Mutation: {
createPost: (_, { title, content, authorId }) => {
const post = { id: String(posts.length + 1), title, content, authorId };
posts.push(post);
pubsub.publish("POST_CREATED", { postCreated: post });
return post;
}
}
};
Security Best Practices
- Depth limiting — Prevent deeply nested queries from overloading the server
- Query cost analysis — Assign costs to fields and reject expensive queries
- Rate Limiting — Limit requests per client like REST APIs
- Authentication — Validate tokens in context, not in individual resolvers
- Authorization — Check permissions at the field level
const depthLimit = require("Graphql-depth-limit");
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(7)]
});
Common Errors
N+1 query problem — Making individual database queries for each related object. Use DataLoader to batch and cache queries.
Overly permissive schema — Exposing internal database fields through GraphQL. Design the schema for clients, not the database.
No pagination — Returning all records in a single query. Always paginate list fields. Use Cursor-based pagination (Relay spec) for consistency.
Expensive queries — Allowing clients to request deeply nested data that causes performance issues. Implement query cost analysis and depth limiting.
Ignoring nullability — Marking every field as non-null (
String!). A single null field from the database crashes the entire response.No error handling in resolvers — Throwing raw errors that leak Stack traces. Catch errors and return user-friendly error messages.
Missing authentication context — Checking auth inside every resolver individually. Set up authentication middleware that populates the context once.
Practice Questions
- How does GraphQL differ from REST in terms of data fetching?
- What is the N+1 Problem and how does DataLoader solve it?
- What is the difference between a Query and a Mutation?
- How do you implement real-time features in GraphQL?
- What security measures should you implement for a GraphQL API?
Challenge
Build a GraphQL API for a project management system. Implement types for Project, Task, User, and Comment. Include queries with filtering and pagination, mutations for CRUD operations, subscriptions for task status changes, DataLoader for batching related data, depth limiting and query cost analysis for security. Deploy with Apollo Server and test with Apollo Sandbox.
FAQ
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro