Back to blog

What is GraphQL? Complete Guide for Developers

graphqlapibackendweb-development
What is GraphQL? Complete Guide for Developers

REST has been the dominant API architecture for over a decade. But as applications grew more complex—with mobile clients needing different data than web clients, and frontend teams waiting on backend changes—a new approach emerged. In 2015, Facebook open-sourced GraphQL, a query language that gives clients the power to ask for exactly the data they need.

In this comprehensive guide, you'll learn everything you need to know about GraphQL—from core concepts to practical implementation patterns.

What You'll Learn

✅ What GraphQL is and the problems it solves
✅ How GraphQL differs from REST APIs
✅ Schema definition with types, queries, and mutations
✅ Writing queries, mutations, and subscriptions
✅ How resolvers work and how to implement them
✅ Authentication, error handling, and pagination
✅ N+1 problem and DataLoader pattern
✅ When to use GraphQL vs REST

Prerequisites

Before diving in, you should have:


Part 1: Understanding GraphQL

What is GraphQL?

GraphQL is a query language for APIs and a runtime for executing those queries against your data. Unlike REST, where the server decides what data to return, GraphQL lets the client specify exactly what data it needs.

Key characteristics:

  • Client-driven: Clients request specific fields, not entire resources
  • Single endpoint: All operations go through one URL (typically /graphql)
  • Strongly typed: Every API has a schema that defines all available data and operations
  • Introspective: Clients can query the schema itself to discover what's available

The Problems GraphQL Solves

1. Over-fetching

With REST, endpoints return fixed data structures. If you only need a user's name, you still get their email, address, phone number, and everything else.

REST: GET /api/users/1
Response: { id, name, email, phone, address, avatar, bio, createdAt, ... }
                              ↑ You only needed name!
# GraphQL: Ask for exactly what you need
query {
  user(id: 1) {
    name
  }
}
# Response: { "data": { "user": { "name": "Chanh Le" } } }

2. Under-fetching

Need a user's posts and each post's comments? With REST, that's multiple requests.

REST (3 requests):
  GET /api/users/1
  GET /api/users/1/posts
  GET /api/posts/42/comments   (for each post!)
# GraphQL (1 request):
query {
  user(id: 1) {
    name
    posts {
      title
      comments {
        text
        author { name }
      }
    }
  }
}

3. API Evolution Without Versioning

REST APIs often need versioning (/v1/users, /v2/users) when data structures change. GraphQL avoids this—you add new fields without breaking existing clients, and deprecate old fields gracefully.

type User {
  name: String!
  fullName: String!       # New field added
  username: String @deprecated(reason: "Use 'name' instead")
}

How GraphQL Works

Every GraphQL API has three pillars:

  1. Schema: Defines what data is available and what operations are allowed
  2. Queries/Mutations: The language clients use to request or modify data
  3. Resolvers: Functions that fetch the actual data for each field

Part 2: GraphQL Schema & Type System

The schema is the heart of every GraphQL API. It defines the contract between client and server.

Scalar Types

GraphQL has five built-in scalar types:

type Example {
  id: ID!            # Unique identifier (serialized as String)
  name: String!      # UTF-8 string
  age: Int!          # 32-bit integer
  score: Float!      # Double-precision floating point
  active: Boolean!   # true or false
}

The ! means the field is non-nullable—it will always return a value.

Object Types

Object types are the building blocks of your schema:

type User {
  id: ID!
  name: String!
  email: String!
  age: Int
  posts: [Post!]!     # List of Post objects (list and items non-null)
  profile: Profile    # Nullable - might not have a profile
}
 
type Post {
  id: ID!
  title: String!
  content: String!
  published: Boolean!
  author: User!       # Every post has an author
  tags: [String!]!
  createdAt: String!
}
 
type Profile {
  bio: String
  website: String
  avatar: String
}

Nullability Rules

Understanding nullability is critical in GraphQL:

type Example {
  a: String          # Nullable - can return null
  b: String!         # Non-null - must return a String
  c: [String]        # Nullable list of nullable strings
  d: [String]!       # Non-null list of nullable strings
  e: [String!]!      # Non-null list of non-null strings
}
Field    | null   | []     | [null]  | ["hi"]  | ["hi", null]
---------|--------|--------|---------|---------|-------------
[String]   ✅       ✅       ✅        ✅        ✅
[String]!  ❌       ✅       ✅        ✅        ✅
[String!]! ❌       ✅       ❌        ✅        ❌

Enum Types

Restrict a field to a specific set of values:

enum Role {
  ADMIN
  EDITOR
  VIEWER
}
 
enum PostStatus {
  DRAFT
  PUBLISHED
  ARCHIVED
}
 
type User {
  id: ID!
  name: String!
  role: Role!
}
 
type Post {
  id: ID!
  title: String!
  status: PostStatus!
}

Input Types

Input types define the shape of data for mutations:

input CreateUserInput {
  name: String!
  email: String!
  age: Int
  role: Role = VIEWER    # Default value
}
 
input UpdateUserInput {
  name: String
  email: String
  age: Int
}
 
input PostFilterInput {
  status: PostStatus
  authorId: ID
  tag: String
  limit: Int = 10
  offset: Int = 0
}

Interface Types

Interfaces define a set of fields that multiple types must implement:

interface Node {
  id: ID!
}
 
interface Timestamped {
  createdAt: String!
  updatedAt: String!
}
 
type User implements Node & Timestamped {
  id: ID!
  name: String!
  email: String!
  createdAt: String!
  updatedAt: String!
}
 
type Post implements Node & Timestamped {
  id: ID!
  title: String!
  content: String!
  createdAt: String!
  updatedAt: String!
}

Union Types

Unions represent a value that could be one of several types:

union SearchResult = User | Post | Comment
 
type Query {
  search(term: String!): [SearchResult!]!
}

When querying a union, use inline fragments to specify fields per type:

query {
  search(term: "graphql") {
    ... on User {
      name
      email
    }
    ... on Post {
      title
      content
    }
    ... on Comment {
      text
      author { name }
    }
  }
}

Part 3: Queries

Queries are how clients read data from a GraphQL API.

Basic Queries

# Define available queries in the schema
type Query {
  user(id: ID!): User
  users(limit: Int, offset: Int): [User!]!
  post(id: ID!): Post
  posts(filter: PostFilterInput): [Post!]!
}

Simple query:

query {
  user(id: "1") {
    name
    email
  }
}

Response:

{
  "data": {
    "user": {
      "name": "Chanh Le",
      "email": "chanh@example.com"
    }
  }
}

Nested Queries

One of GraphQL's greatest strengths—fetch related data in a single request:

query {
  user(id: "1") {
    name
    posts {
      title
      published
      tags
      comments {
        text
        author {
          name
        }
      }
    }
  }
}

Named Queries and Variables

In production, always use named queries with variables:

query GetUserWithPosts($userId: ID!, $includeComments: Boolean!) {
  user(id: $userId) {
    name
    email
    posts {
      title
      content
      comments @include(if: $includeComments) {
        text
        author { name }
      }
    }
  }
}

Variables (sent as JSON):

{
  "userId": "1",
  "includeComments": true
}

Query Aliases

Fetch the same field multiple times with different arguments:

query {
  admin: user(id: "1") {
    name
    role
  }
  editor: user(id: "2") {
    name
    role
  }
}

Response:

{
  "data": {
    "admin": { "name": "Alice", "role": "ADMIN" },
    "editor": { "name": "Bob", "role": "EDITOR" }
  }
}

Fragments

Reuse field selections across queries:

fragment UserBasicInfo on User {
  id
  name
  email
  role
}
 
query {
  admin: user(id: "1") {
    ...UserBasicInfo
    posts { title }
  }
  editor: user(id: "2") {
    ...UserBasicInfo
  }
}

Directives

Control query execution with built-in directives:

query GetUser($userId: ID!, $withEmail: Boolean!, $skipPosts: Boolean!) {
  user(id: $userId) {
    name
    email @include(if: $withEmail)
    posts @skip(if: $skipPosts) {
      title
    }
  }
}
  • @include(if: Boolean): Include field only when condition is true
  • @skip(if: Boolean): Skip field when condition is true

Part 4: Mutations

Mutations are how clients create, update, and delete data.

Defining Mutations

type Mutation {
  createUser(input: CreateUserInput!): User!
  updateUser(id: ID!, input: UpdateUserInput!): User!
  deleteUser(id: ID!): Boolean!
 
  createPost(input: CreatePostInput!): Post!
  updatePost(id: ID!, input: UpdatePostInput!): Post!
  deletePost(id: ID!): Boolean!
  publishPost(id: ID!): Post!
}
 
input CreatePostInput {
  title: String!
  content: String!
  tags: [String!]
}
 
input UpdatePostInput {
  title: String
  content: String
  tags: [String!]
}

Executing Mutations

Create:

mutation CreateNewUser($input: CreateUserInput!) {
  createUser(input: $input) {
    id
    name
    email
    role
  }
}
{
  "input": {
    "name": "Chanh Le",
    "email": "chanh@example.com",
    "role": "ADMIN"
  }
}

Update:

mutation UpdateExistingUser($id: ID!, $input: UpdateUserInput!) {
  updateUser(id: $id, input: $input) {
    id
    name
    email
  }
}

Delete:

mutation DeleteUser($id: ID!) {
  deleteUser(id: $id)
}

Mutation Response Patterns

A common pattern is to return both the result and potential errors:

type MutationResponse {
  success: Boolean!
  message: String
  user: User
}
 
type Mutation {
  createUser(input: CreateUserInput!): MutationResponse!
}
mutation {
  createUser(input: { name: "Alice", email: "alice@example.com" }) {
    success
    message
    user {
      id
      name
    }
  }
}

Part 5: Subscriptions

Subscriptions enable real-time updates via WebSockets.

Defining Subscriptions

type Subscription {
  postCreated: Post!
  postUpdated(id: ID!): Post!
  commentAdded(postId: ID!): Comment!
  userStatusChanged: User!
}

Using Subscriptions

subscription OnNewPost {
  postCreated {
    id
    title
    author {
      name
    }
  }
}

When a new post is created, the server pushes data to all subscribed clients:

{
  "data": {
    "postCreated": {
      "id": "42",
      "title": "GraphQL Best Practices",
      "author": { "name": "Chanh Le" }
    }
  }
}

How Subscriptions Work


Part 6: Resolvers

Resolvers are the functions that fetch data for each field in your schema. They are the bridge between your schema and your data sources.

Resolver Function Signature

Every resolver receives four arguments:

const resolver = (parent, args, context, info) => {
  // parent:  The return value of the parent resolver
  // args:    Arguments passed to the field
  // context: Shared data (auth, database, etc.)
  // info:    Query execution metadata
};

Basic Resolver Example

// Schema
const typeDefs = `
  type Query {
    user(id: ID!): User
    users: [User!]!
  }
 
  type User {
    id: ID!
    name: String!
    email: String!
    posts: [Post!]!
  }
 
  type Post {
    id: ID!
    title: String!
    author: User!
  }
`;
 
// Resolvers
const resolvers = {
  Query: {
    user: (parent, { id }, context) => {
      return context.db.users.findById(id);
    },
    users: (parent, args, context) => {
      return context.db.users.findAll();
    },
  },
 
  User: {
    // Resolver for the 'posts' field on User type
    posts: (parent, args, context) => {
      // 'parent' is the User object returned by the parent resolver
      return context.db.posts.findByAuthorId(parent.id);
    },
  },
 
  Post: {
    // Resolver for the 'author' field on Post type
    author: (parent, args, context) => {
      return context.db.users.findById(parent.authorId);
    },
  },
};

Resolver Chain

GraphQL resolves fields top-down. Each nested field triggers its own resolver:

Query.user(id: "1")
  → returns { id: "1", name: "Chanh", email: "chanh@example.com" }
 
User.posts(parent = above user)
  → returns [{ id: "10", title: "GraphQL Guide", authorId: "1" }]
 
Post.author(parent = above post)
  → returns { id: "1", name: "Chanh", email: "chanh@example.com" }
query {
  user(id: "1") {          ← Query.user resolver
    name                    ← Default resolver (reads parent.name)
    posts {                 ← User.posts resolver
      title                 ← Default resolver (reads parent.title)
      author {              ← Post.author resolver
        name                ← Default resolver (reads parent.name)
      }
    }
  }
}

Default Resolvers

If you don't define a resolver for a field, GraphQL uses a default resolver that returns parent[fieldName]. That's why you only need custom resolvers for computed or related fields.

Context Setup

The context is created per-request and shared across all resolvers:

const server = new ApolloServer({
  typeDefs,
  resolvers,
});
 
const { url } = await startStandaloneServer(server, {
  context: async ({ req }) => ({
    // Database connection
    db: database,
 
    // Authenticated user
    user: await authenticateUser(req.headers.authorization),
 
    // DataLoaders (for batching)
    loaders: createLoaders(),
  }),
});

Part 7: Practical Implementation

Let's build a complete GraphQL API with Node.js and Apollo Server.

Project Setup

mkdir graphql-blog-api
cd graphql-blog-api
npm init -y
npm install @apollo/server graphql

Complete Server Example

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
 
// Type definitions (schema)
const typeDefs = `#graphql
  type User {
    id: ID!
    name: String!
    email: String!
    role: Role!
    posts: [Post!]!
  }
 
  type Post {
    id: ID!
    title: String!
    content: String!
    published: Boolean!
    author: User!
    tags: [String!]!
    createdAt: String!
  }
 
  enum Role {
    ADMIN
    EDITOR
    VIEWER
  }
 
  input CreatePostInput {
    title: String!
    content: String!
    tags: [String!]
  }
 
  input UpdatePostInput {
    title: String
    content: String
    tags: [String!]
    published: Boolean
  }
 
  type Query {
    users: [User!]!
    user(id: ID!): User
    posts(published: Boolean): [Post!]!
    post(id: ID!): Post
  }
 
  type Mutation {
    createPost(input: CreatePostInput!): Post!
    updatePost(id: ID!, input: UpdatePostInput!): Post!
    deletePost(id: ID!): Boolean!
  }
`;
 
// Sample data
let users = [
  { id: '1', name: 'Alice', email: 'alice@example.com', role: 'ADMIN' },
  { id: '2', name: 'Bob', email: 'bob@example.com', role: 'EDITOR' },
];
 
let posts = [
  {
    id: '1',
    title: 'Getting Started with GraphQL',
    content: 'GraphQL is a query language for APIs...',
    published: true,
    authorId: '1',
    tags: ['graphql', 'api'],
    createdAt: '2026-02-01',
  },
  {
    id: '2',
    title: 'Advanced TypeScript Patterns',
    content: 'TypeScript offers powerful type features...',
    published: false,
    authorId: '2',
    tags: ['typescript', 'patterns'],
    createdAt: '2026-02-05',
  },
];
 
// Resolvers
const resolvers = {
  Query: {
    users: () => users,
    user: (_, { id }) => users.find((u) => u.id === id),
    posts: (_, { published }) => {
      if (published !== undefined) {
        return posts.filter((p) => p.published === published);
      }
      return posts;
    },
    post: (_, { id }) => posts.find((p) => p.id === id),
  },
 
  Mutation: {
    createPost: (_, { input }, context) => {
      const newPost = {
        id: String(posts.length + 1),
        ...input,
        published: false,
        authorId: '1', // Would come from auth context
        tags: input.tags || [],
        createdAt: new Date().toISOString(),
      };
      posts.push(newPost);
      return newPost;
    },
    updatePost: (_, { id, input }) => {
      const index = posts.findIndex((p) => p.id === id);
      if (index === -1) throw new Error('Post not found');
      posts[index] = { ...posts[index], ...input };
      return posts[index];
    },
    deletePost: (_, { id }) => {
      const index = posts.findIndex((p) => p.id === id);
      if (index === -1) return false;
      posts.splice(index, 1);
      return true;
    },
  },
 
  User: {
    posts: (parent) => posts.filter((p) => p.authorId === parent.id),
  },
 
  Post: {
    author: (parent) => users.find((u) => u.id === parent.authorId),
  },
};
 
// Start server
const server = new ApolloServer({ typeDefs, resolvers });
 
const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
});
 
console.log(`GraphQL server running at ${url}`);

Testing with GraphQL Playground

Once your server is running, visit http://localhost:4000 to access the Apollo Sandbox. Try these queries:

# Get all users with their posts
query {
  users {
    name
    role
    posts {
      title
      published
    }
  }
}
 
# Create a new post
mutation {
  createPost(input: {
    title: "My New Post"
    content: "This is the content of my new post"
    tags: ["graphql", "tutorial"]
  }) {
    id
    title
    createdAt
  }
}
 
# Get only published posts
query {
  posts(published: true) {
    title
    author {
      name
    }
    tags
  }
}

Part 8: The N+1 Problem & DataLoader

Understanding the N+1 Problem

The N+1 problem is the most common performance issue in GraphQL. It happens when resolving a list of items triggers an individual database query for each item's related data.

query {
  posts {        # 1 query: SELECT * FROM posts (returns 10 posts)
    title
    author {     # 10 queries: SELECT * FROM users WHERE id = ?
      name       #   (one per post!)
    }
  }
}
# Total: 11 queries for 10 posts!

The DataLoader Solution

DataLoader batches multiple individual requests into a single batch request:

npm install dataloader
import DataLoader from 'dataloader';
 
// Without DataLoader: 10 separate queries
// Post.author resolver calls: findUserById(1), findUserById(2), ...
 
// With DataLoader: 1 batched query
const userLoader = new DataLoader(async (userIds: readonly string[]) => {
  // Single query: SELECT * FROM users WHERE id IN (1, 2, 3, ...)
  const users = await db.users.findByIds([...userIds]);
 
  // IMPORTANT: Return results in the same order as the input IDs
  return userIds.map((id) => users.find((u) => u.id === id));
});
 
// Resolvers
const resolvers = {
  Post: {
    author: (parent, args, context) => {
      // DataLoader automatically batches these calls
      return context.loaders.userLoader.load(parent.authorId);
    },
  },
};

DataLoader with Context

Create new DataLoader instances per request to avoid caching issues:

function createLoaders(db) {
  return {
    userLoader: new DataLoader(async (ids) => {
      const users = await db.users.findByIds([...ids]);
      return ids.map((id) => users.find((u) => u.id === id));
    }),
    postLoader: new DataLoader(async (ids) => {
      const posts = await db.posts.findByIds([...ids]);
      return ids.map((id) => posts.find((p) => p.id === id));
    }),
  };
}
 
// In server context
const { url } = await startStandaloneServer(server, {
  context: async ({ req }) => ({
    db: database,
    loaders: createLoaders(database), // Fresh loaders per request
  }),
});

Part 9: Authentication & Authorization

Authentication via Context

Pass authentication data through the context:

const { url } = await startStandaloneServer(server, {
  context: async ({ req }) => {
    const token = req.headers.authorization?.replace('Bearer ', '');
    let user = null;
 
    if (token) {
      try {
        user = await verifyJWT(token);
      } catch (err) {
        // Invalid token - user remains null
      }
    }
 
    return { user, db: database, loaders: createLoaders(database) };
  },
});

Authorization in Resolvers

const resolvers = {
  Mutation: {
    createPost: (_, { input }, context) => {
      // Require authentication
      if (!context.user) {
        throw new GraphQLError('You must be logged in', {
          extensions: { code: 'UNAUTHENTICATED' },
        });
      }
 
      return context.db.posts.create({
        ...input,
        authorId: context.user.id,
      });
    },
 
    deletePost: async (_, { id }, context) => {
      if (!context.user) {
        throw new GraphQLError('You must be logged in', {
          extensions: { code: 'UNAUTHENTICATED' },
        });
      }
 
      const post = await context.db.posts.findById(id);
 
      // Only author or admin can delete
      if (post.authorId !== context.user.id && context.user.role !== 'ADMIN') {
        throw new GraphQLError('Not authorized to delete this post', {
          extensions: { code: 'FORBIDDEN' },
        });
      }
 
      return context.db.posts.delete(id);
    },
  },
};

Field-Level Authorization

Some fields should only be visible to certain users:

const resolvers = {
  User: {
    email: (parent, args, context) => {
      // Only show email to the user themselves or admins
      if (context.user?.id === parent.id || context.user?.role === 'ADMIN') {
        return parent.email;
      }
      return null;
    },
  },
};

Part 10: Error Handling

GraphQL Error Format

GraphQL returns errors in a structured format alongside partial data:

{
  "data": {
    "user": null
  },
  "errors": [
    {
      "message": "User not found",
      "locations": [{ "line": 2, "column": 3 }],
      "path": ["user"],
      "extensions": {
        "code": "NOT_FOUND"
      }
    }
  ]
}

Custom Errors with Error Codes

import { GraphQLError } from 'graphql';
 
// Define custom error codes
const resolvers = {
  Query: {
    user: async (_, { id }, context) => {
      const user = await context.db.users.findById(id);
 
      if (!user) {
        throw new GraphQLError(`User with ID ${id} not found`, {
          extensions: {
            code: 'NOT_FOUND',
            argumentName: 'id',
          },
        });
      }
 
      return user;
    },
  },
 
  Mutation: {
    createUser: async (_, { input }, context) => {
      const existing = await context.db.users.findByEmail(input.email);
 
      if (existing) {
        throw new GraphQLError('A user with this email already exists', {
          extensions: {
            code: 'CONFLICT',
            field: 'email',
          },
        });
      }
 
      return context.db.users.create(input);
    },
  },
};

Input Validation

const resolvers = {
  Mutation: {
    createPost: (_, { input }, context) => {
      const errors = [];
 
      if (input.title.length < 3) {
        errors.push('Title must be at least 3 characters');
      }
      if (input.title.length > 200) {
        errors.push('Title must be less than 200 characters');
      }
      if (input.content.length < 10) {
        errors.push('Content must be at least 10 characters');
      }
 
      if (errors.length > 0) {
        throw new GraphQLError('Validation failed', {
          extensions: {
            code: 'BAD_USER_INPUT',
            validationErrors: errors,
          },
        });
      }
 
      return context.db.posts.create(input);
    },
  },
};

Part 11: Pagination

Offset-Based Pagination

Simple but has consistency issues with dynamic data:

type Query {
  posts(limit: Int = 10, offset: Int = 0): PostConnection!
}
 
type PostConnection {
  items: [Post!]!
  totalCount: Int!
  hasMore: Boolean!
}
query {
  posts(limit: 10, offset: 0) {
    items {
      title
    }
    totalCount
    hasMore
  }
}

Cursor-Based Pagination (Relay Style)

More robust, especially for real-time data:

type Query {
  posts(first: Int, after: String, last: Int, before: String): PostConnection!
}
 
type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}
 
type PostEdge {
  node: Post!
  cursor: String!
}
 
type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}
# First page
query {
  posts(first: 10) {
    edges {
      node { title }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}
 
# Next page (use endCursor from previous response)
query {
  posts(first: 10, after: "Y3Vyc29yOjEw") {
    edges {
      node { title }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

Implementation

const resolvers = {
  Query: {
    posts: async (_, { first = 10, after }, context) => {
      let query = context.db.posts.query().orderBy('createdAt', 'desc');
 
      if (after) {
        const cursor = decodeCursor(after); // Base64 decode
        query = query.where('createdAt', '<', cursor);
      }
 
      const posts = await query.limit(first + 1).execute(); // Fetch one extra
      const hasNextPage = posts.length > first;
      const edges = posts.slice(0, first).map((post) => ({
        node: post,
        cursor: encodeCursor(post.createdAt), // Base64 encode
      }));
 
      return {
        edges,
        pageInfo: {
          hasNextPage,
          hasPreviousPage: !!after,
          startCursor: edges[0]?.cursor,
          endCursor: edges[edges.length - 1]?.cursor,
        },
        totalCount: await context.db.posts.count(),
      };
    },
  },
};
 
function encodeCursor(value) {
  return Buffer.from(String(value)).toString('base64');
}
 
function decodeCursor(cursor) {
  return Buffer.from(cursor, 'base64').toString('utf-8');
}

Part 12: GraphQL vs REST — When to Use Which

Side-by-Side Comparison

FeatureRESTGraphQL
EndpointsMultiple (/users, /posts)Single (/graphql)
Data fetchingServer decides response shapeClient decides response shape
Over-fetchingCommonEliminated
Under-fetchingCommon (multiple requests)Eliminated (nested queries)
VersioningURL-based (/v1, /v2)Schema evolution (deprecation)
CachingHTTP caching (easy)More complex (needs custom)
File uploadNative supportRequires extra setup
Error handlingHTTP status codesAlways 200, errors in body
Learning curveLowerHigher
ToolingMature ecosystemGrowing ecosystem
Real-timeWebhooks, SSE, pollingBuilt-in subscriptions

When to Choose REST

✅ Simple CRUD APIs with predictable data shapes
✅ Public APIs consumed by many third parties
✅ Microservices that need HTTP caching
✅ File upload/download heavy APIs
✅ Teams new to API development

When to Choose GraphQL

✅ Multiple client types needing different data (mobile, web, IoT)
✅ Complex data relationships with deep nesting
✅ Rapidly evolving frontend requirements
✅ Real-time features (chat, notifications, live feeds)
✅ APIs where over/under-fetching is a real performance problem

Can You Use Both?

Absolutely. Many organizations use REST for simple services and GraphQL as a gateway that aggregates multiple REST APIs:


Part 13: Best Practices

Schema Design

  1. Use descriptive type names: User, not Usr or UserType
  2. Use input types for mutations: Separates input from output types
  3. Make fields non-nullable by default: Add ! unless null is a valid response
  4. Use enums for fixed sets of values: Prevents invalid data
  5. Design for the client, not the database: Your schema should reflect what clients need, not your database structure

Naming Conventions

# Types: PascalCase
type BlogPost { ... }
 
# Fields: camelCase
type User {
  firstName: String!
  lastName: String!
}
 
# Enums: SCREAMING_SNAKE_CASE values
enum UserRole {
  SUPER_ADMIN
  CONTENT_EDITOR
}
 
# Mutations: verb + noun
type Mutation {
  createUser(input: CreateUserInput!): User!
  updateUserProfile(id: ID!, input: UpdateProfileInput!): User!
  deleteComment(id: ID!): Boolean!
}

Security

  1. Query depth limiting: Prevent deeply nested queries that could overload your server
# Dangerous: unlimited nesting
query {
  user { posts { author { posts { author { posts { ... } } } } } }
}
  1. Query complexity analysis: Assign costs to fields and reject queries exceeding a threshold
  2. Rate limiting: Limit queries per client per time window
  3. Input validation: Validate all input data in resolvers
  4. Disable introspection in production: Prevent schema discovery by attackers

Performance

  1. Always use DataLoader for related data to solve the N+1 problem
  2. Persist queries: Send query IDs instead of full query strings (reduces payload, prevents arbitrary queries)
  3. Use cursor-based pagination for large datasets
  4. Monitor resolver execution time to identify slow resolvers
  5. Cache at the resolver level when appropriate

Summary

GraphQL is a powerful alternative to REST that gives clients precise control over their data. Here's what we covered:

✅ GraphQL is a query language that lets clients ask for exactly the data they need
✅ Schemas define the API contract with a strong type system
✅ Queries read data, mutations modify data, subscriptions enable real-time updates
✅ Resolvers are functions that fetch data for each field
✅ DataLoader solves the N+1 problem by batching database queries
✅ Authentication flows through the context object
✅ Cursor-based pagination is more robust than offset-based
✅ GraphQL and REST each have strengths—choose based on your needs

What's Next?

Now that you understand GraphQL fundamentals, you can:


Related Posts:
What is REST API? Complete Guide
HTTP Protocol Complete Guide
Server-Client Architecture Explained

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.