GraphQL #

GraphQL is often either adopted too quickly or rejected too quickly. Some teams adopt it because it sounds modern, without any real need justifying its added complexity. Others reject it because “REST is enough”, even when there are real problems GraphQL solves far more elegantly. To decide correctly, you need to understand where GraphQL came from, what problems it was designed to solve, how it works, and what costs come with it. This article covers all of that — from schema design to DataLoader, from query complexity limiting to when you should just stick with REST.

Why GraphQL Exists #

Facebook built GraphQL in 2012 to solve a real problem in their mobile app. The Facebook news feed needs data from dozens of different sources — posts, photos, videos, comments, reactions, friend information — and every UI component needs a different data subset.

With REST, there are two equally bad options:

Option 1: One endpoint per UI need
  GET /newsfeed/posts
  GET /newsfeed/photos
  GET /newsfeed/friends-activity
  → Many requests, many round trips, slow on mobile
  → Every UI change requires a new backend endpoint

Option 2: One big endpoint returning all data
  GET /newsfeed
  → Over-fetching: mobile receives data not all of which is used
  → Wastes bandwidth on limited mobile networks
  → Slow responses

GraphQL is the solution to this dilemma: one endpoint, but the client decides exactly what data it needs. Facebook used it internally for three years before open-sourcing it in 2015.

flowchart LR
    subgraph REST["REST — the server defines the data"]
        RC["Client"]
        RE1["GET /users/1\n→ the entire user profile"]
        RE2["GET /users/1/posts\n→ all posts"]
        RE3["GET /users/1/followers\n→ all followers"]
        RC -->|requests| RE1
        RC -->|requests| RE2
        RC -->|requests| RE3
    end

    subgraph GQL["GraphQL — the client defines the data"]
        GC["Client"]
        GE["POST /graphql\n→ only the requested fields"]
        GC -->|request| GE
    end

How GraphQL Works #

Schema — The Contract Defining Everything #

In GraphQL, the schema is the single source of truth. It defines all available types, relationships between types, and operations that can be performed. Schemas are written in SDL (Schema Definition Language).

# Type definitions
type User {
  id: ID!
  name: String!
  email: String!
  createdAt: String!
  posts: [Post!]!
  followers: [User!]!
}

type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
  publishedAt: String
  tags: [String!]!
}

# Root types — the entry points for all operations
type Query {
  user(id: ID!): User
  users(limit: Int, offset: Int): [User!]!
  post(id: ID!): Post
}

type Mutation {
  createPost(input: CreatePostInput!): Post!
  updatePost(id: ID!, input: UpdatePostInput!): Post!
  deletePost(id: ID!): Boolean!
}

type Subscription {
  postCreated: Post!
  commentAdded(postId: ID!): Comment!
}

The ! mark means non-nullable — that field is guaranteed not to be null if the request succeeds.

The Three GraphQL Operations #

flowchart TD
    subgraph Ops["Three GraphQL Operations"]
        Q["Query\nRead data\n(like GET in REST)"]
        M["Mutation\nChange data\n(like POST/PUT/DELETE in REST)"]
        S["Subscription\nReal-time updates\n(like WebSocket)"]
    end

    Q -->|"query { user(id: 1) { name } }"| Qex["Response:\n{ user: { name: 'Budi' } }"]
    M -->|"mutation { createPost(input: {...}) { id } }"| Mex["Response:\n{ createPost: { id: '123' } }"]
    S -->|"subscription { postCreated { id title } }"| Sex["Push notifications\nwhen a new post arrives"]

Queries — the Client Defines the Data Shape #

# The client requests exactly the data it needs
query GetUserProfile {
  user(id: "123") {
    name
    email
    posts {
      title
      publishedAt
    }
    followers {
      name
    }
  }
}

# Response — only the requested data, nothing more
{
  "data": {
    "user": {
      "name": "Budi Santoso",
      "email": "[email protected]",
      "posts": [
        { "title": "Learning GraphQL", "publishedAt": "2024-01-15" }
      ],
      "followers": [
        { "name": "Ani" },
        { "name": "Candra" }
      ]
    }
  }
}

Mutations — Changing Data #

# Mutation with an input type
mutation CreateNewPost($input: CreatePostInput!) {
  createPost(input: $input) {
    id
    title
    author {
      name
    }
  }
}

# Variables
{
  "input": {
    "title": "GraphQL Guide",
    "content": "...",
    "tags": ["graphql", "api"]
  }
}

Variables — Separating Queries from Data #

# ANTI-PATTERN: Hardcoding values directly in queries (unsafe, not reusable)
query {
  user(id: "123") { name }
}

# CORRECT: Use variables
query GetUser($userId: ID!) {
  user(id: $userId) { name }
}
# Variables sent separately: { "userId": "123" }
# Safer (prevents injection), more reusable

The N+1 Problem and the DataLoader Solution #

This is the most critical problem to understand before implementing GraphQL in production. The N+1 query problem is a condition where one GraphQL query produces N+1 database queries.

sequenceDiagram
    participant C as Client
    participant R as GraphQL Resolver
    participant DB as Database

    C->>R: query { posts { title author { name } } }

    Note over R,DB: Without DataLoader — N+1 problem
    R->>DB: SELECT * FROM posts LIMIT 10
    DB-->>R: 10 posts

    loop For every post
        R->>DB: SELECT * FROM users WHERE id = ?
        DB-->>R: 1 user
    end

    Note over R,DB: Total: 1 + 10 = 11 database queries!

    Note over R,DB: With DataLoader — only 2 queries
    R->>DB: SELECT * FROM posts LIMIT 10
    DB-->>R: 10 posts
    R->>DB: SELECT * FROM users WHERE id IN (1,2,3,...,10)
    DB-->>R: 10 users at once

Why N+1 Happens #

Every resolver in GraphQL operates independently. The author resolver on the Post type doesn’t know it will be called 10 times for 10 different posts — it only knows “I need to fetch the user with this id”.

// ANTI-PATTERN: A naive resolver
const resolvers = {
  Post: {
    author: async (post) => {
      // This is called ONCE PER POST → N+1 problem
      return await db.users.findById(post.authorId);
    }
  }
}

// CORRECT: With DataLoader
const userLoader = new DataLoader(async (userIds) => {
  // Called ONCE with all the IDs at once
  const users = await db.users.findByIds(userIds);
  return userIds.map(id => users.find(u => u.id === id));
});

const resolvers = {
  Post: {
    author: (post) => userLoader.load(post.authorId)
    // DataLoader collects all IDs within one tick,
    // then performs one batch query
  }
}

DataLoader does two things: batching (collecting individual requests into one batch query) and caching (the same result within one request isn’t fetched twice).


Good Schema Design #

A good schema reflects the business domain, not the database structure. This is the most important difference between a schema that evolves easily and one that becomes technical debt.

Domain-Driven, Not Database-Driven #

# ANTI-PATTERN: A schema reflecting the database
type User {
  user_id: Int!          # database column name
  created_timestamp: Int # Unix timestamp from the DB
  is_deleted: Boolean    # soft delete flag from the DB
  role_id: Int           # foreign key from the DB
}

# CORRECT: A schema reflecting the domain
type User {
  id: ID!
  createdAt: String!
  status: UserStatus!    # meaningful enum
  role: UserRole!        # rich type, not an ID
}

enum UserStatus {
  ACTIVE
  SUSPENDED
  DEACTIVATED
}

enum UserRole {
  ADMIN
  EDITOR
  VIEWER
}

Input Types for Mutations #

# ANTI-PATTERN: Many individual arguments
mutation {
  createUser(name: String!, email: String!, role: UserRole!, teamId: ID!)
}

# CORRECT: Use input types — easier to evolve
input CreateUserInput {
  name: String!
  email: String!
  role: UserRole!
  teamId: ID!
}

mutation {
  createUser(input: CreateUserInput!): User!
}
# Adding a new field to CreateUserInput isn't a breaking change

Cursor-Based Pagination #

# Cursor-based pagination (recommended for GraphQL)
type UserConnection {
  edges: [UserEdge!]!
  pageInfo: PageInfo!
}

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

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

type Query {
  users(first: Int, after: String, last: Int, before: String): UserConnection!
}

# Query:
query {
  users(first: 10, after: "cursor123") {
    edges {
      node { id name }
      cursor
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

This pattern is known as the Relay Cursor Connection Spec and is the de-facto standard for GraphQL pagination.


Query Complexity and Depth Limiting #

Without restrictions, clients can send very expensive queries to the server:

# A query that could collapse the server
query MaliciousQuery {
  users {
    followers {
      followers {
        followers {
          followers {
            posts {
              comments {
                author {
                  followers { name }
                }
              }
            }
          }
        }
      }
    }
  }
}

There are two main strategies for preventing this:

Depth Limiting #

Limit query nesting depth:
  Depth limit: 5

The query above has a depth of 8 → rejected before execution

Implementation (example with graphql-depth-limit in Node.js):
  import depthLimit from 'graphql-depth-limit'
  
  const server = new ApolloServer({
    validationRules: [depthLimit(5)]
  })

Complexity Scoring #

Give every field a "cost":
  Scalar field: 1 point
  Object field: 1 point
  List field: 10 points (because it can return many items)
  
The query above:
  users (list): 10
  followers (list) × users: can be very large

Maximum complexity: for example 1000 points

Queries exceeding the limit → rejected with a clear error:
  "Query complexity 4320 exceeds maximum allowed complexity of 1000"
Query complexity limiting must be implemented before a GraphQL API is exposed publicly or even to uncontrolled internal consumers. Without it, one accidentally or deliberately expensive query can make the entire service unresponsive. This isn’t an optimization — it’s basic security.

Caching in GraphQL #

Caching is one of GraphQL’s biggest challenges because all requests use POST to a single endpoint — traditional HTTP caching can’t be leveraged directly.

Available Caching Strategies #

1. Persisted Queries
   Clients send a hash of the query instead of the full query string
   → Can be executed via GET with the hash as a parameter
   → GET requests can be cached by CDNs and HTTP caches
   
   GET /graphql?operationName=GetUser&extensions={"persistedQuery":{"sha256Hash":"abc123"}}

2. Server-Side Response Caching
   Cache resolver results per field or per query
   → Redis or in-memory caching at the resolver level
   → Needs a proper invalidation strategy
   
   @cacheControl(maxAge: 300)  # cache 5 minutes
   type Product {
     id: ID!
     name: String!  
     price: Float! @cacheControl(maxAge: 30)  # changes more often
   }

3. Client-Side Caching
   Apollo Client and Relay have sophisticated normalized caches
   → Store data per entity, not per query
   → Updating one entity automatically refreshes all queries using it

4. DataLoader Caching
   Already discussed — per-request caching to avoid
   fetching the same data twice within one request

Schema Versioning and Evolution #

One of GraphQL’s big advantages is its ability to evolve without explicit versioning — but this requires discipline.

# Adding a new field → SAFE, not breaking
type User {
  id: ID!
  name: String!
  email: String!
  phoneNumber: String  # new field, nullable → old clients unaffected
}

# Renaming a field → BREAKING (use deprecation)
type User {
  id: ID!
  name: String!
  username: String @deprecated(reason: "Use 'name' instead. Will be removed 2025-01-01")
  # Still present for backward compatibility, but marked deprecated
}

# Removing a field → ONLY after a sufficient deprecation period
# Step 1: mark deprecated + communicate to consumers
# Step 2: monitor whether it's still used (via field usage metrics)
# Step 3: remove once nobody uses it
A healthy deprecation timeline:
  Month 1: Add the new field, deprecate the old one
  Months 2-4: Monitor old field usage through observability
  Month 5: If usage is zero → remove the field
  If still in use → communicate to consumers again

Monitoring and Observability #

GraphQL needs different monitoring than REST. In REST, you can monitor per endpoint (/users, /orders). In GraphQL, all requests go to one endpoint — what needs monitoring is per operation.

Metrics to track:

Per operation (not per endpoint):
  → Latency per query/mutation (P50, P95, P99)
  → Error rate per operation
  → Frequency each operation is called

Per field/resolver:
  → Resolver latency per field
  → Field usage — which fields are never requested?
     (candidates for schema removal)

System level:
  → Query complexity distribution
  → Depth distribution
  → DataLoader batch sizes

Commonly used tools:
  → Apollo Studio (operation tracking, field usage)
  → GraphQL Yoga with custom plugins
  → Prometheus + Grafana for existing systems
Field usage metrics are very valuable for schema evolution — you can know with certainty whether a field is still used before removing it. Apollo Studio provides this feature built-in. Without this visibility, schemas grow without ever being prunable.

Hybrid Architecture — GraphQL and REST Together #

GraphQL doesn’t have to fully replace REST. A hybrid approach is often more pragmatic and healthier.

flowchart TD
    WebClient["Web Client (React)"]
    MobileClient["Mobile Client (Flutter)"]
    GQL["GraphQL API\n(BFF Layer)"]
    UserSvc["User Service\n(REST/gRPC)"]
    OrderSvc["Order Service\n(REST/gRPC)"]
    ProductSvc["Product Service\n(REST/gRPC)"]
    PaymentSvc["Payment Service\n(REST/gRPC)"]

    WebClient -->|"GraphQL Query"| GQL
    MobileClient -->|"GraphQL Query"| GQL
    GQL -->|"Internal REST/gRPC"| UserSvc
    GQL -->|"Internal REST/gRPC"| OrderSvc
    GQL -->|"Internal REST/gRPC"| ProductSvc
    GQL -->|"Internal REST/gRPC"| PaymentSvc

This pattern is known as BFF (Backend for Frontend) — GraphQL acts as the aggregation layer facing clients, while the services behind it keep using REST or gRPC for internal communication.

The advantage: clients get GraphQL flexibility, while internal services stay simple and cacheable with traditional HTTP caching.


GraphQL Anti-Patterns to Avoid #

Schemas Reflecting the Database #

# ✗ Schemas leaking database details:
type OrderItem {
  order_item_id: Int!      # database primary key
  order_fk: Int!           # foreign key
  product_fk: Int!         # foreign key
  qty: Int!                # abbreviation
  unit_price_cents: Int!   # implementation detail (cents)
}

# ✓ Domain-friendly schemas:
type OrderItem {
  id: ID!
  order: Order!            # direct relation, not an FK
  product: Product!        # direct relation, not an FK
  quantity: Int!           # full name
  unitPrice: Money!        # rich type
}

type Money {
  amount: Float!
  currency: String!
}

God Queries — One Query for Everything #

# ✗ A god query fetching all data at once:
query GetEverything {
  currentUser {
    profile { ... }
    orders { ... }
    notifications { ... }
    recommendations { ... }
    recentSearches { ... }
    savedItems { ... }
  }
}
 Slow because all data is fetched at once even when not all is displayed

# ✓ Queries specific to pages or components:
# On the profile page:
query GetUserProfile { currentUser { profile { ... } } }
# On the orders page:
query GetUserOrders { currentUser { orders { ... } } }
# In the notification sidebar:
query GetNotifications { currentUser { notifications { ... } } }

No Proper Error Handling #

# ✗ GraphQL always returns 200 OK, even for errors
# Without proper handling, errors are easy to miss:
{
  "data": {
    "user": null     null without explaining why
  }
}

# ✓ Use explicit error handling:
{
  "data": {
    "user": null
  },
  "errors": [
    {
      "message": "User not found",
      "extensions": {
        "code": "USER_NOT_FOUND",
        "path": ["user"]
      }
    }
  ]
}

# Or use union types for predictable errors:
union UserResult = User | UserNotFoundError | UnauthorizedError

type Query {
  user(id: ID!): UserResult!
}

GraphQL as an ORM over HTTP #

# ✗ GraphQL too close to the database:
mutation {
  updateUser(
    where: { id: { eq: "123" } }
    set: { name: "Budi", email: "[email protected]" }
  )
}
 This is a database query, not an API
 Business logic, validation, and authorization are bypassed

# ✓ Mutations reflecting domain operations:
mutation {
  updateUserProfile(input: {
    userId: "123"
    name: "Budi"
    email: "[email protected]"
  })
}
 The resolver applies business rules and validation

When to Use GraphQL vs REST #

Use GraphQL when there's a real need for:
  ✓ Multiple different clients (web, mobile, widgets) with different data needs
  ✓ Very dynamic, data-hungry UIs
  ✓ Over-fetching is already a measurable problem (not an assumption)
  ✓ Frontend teams need iteration speed not dependent on the backend
  ✓ Aggregating data from several services (BFF pattern)

Keep using REST if:
  ✓ Simple APIs with clear, stable endpoints
  ✓ Public APIs needing full HTTP cacheability
  ✓ The team isn't familiar with GraphQL and has no bandwidth to learn
  ✓ The main use cases are simple CRUD without significant query variations
  ✓ Simple file uploads are needed (REST is more natural for this)

Signs GraphQL might be over-engineering:
  ✗ Only one client type exists (e.g. web only)
  ✗ Data structures on each page don't differ much
  ✗ A small team with good velocity is already using REST
  ✗ No real, measurable over-fetching problem

Healthy GraphQL Checklist #

SCHEMA DESIGN:
  □ Schema reflects the business domain, not the database schema
  □ Field names camelCase and consistent
  □ Input types used for all mutations
  □ Enums used for limited values (not free strings)
  □ Nullable vs non-nullable carefully considered

PERFORMANCE AND SECURITY:
  □ DataLoader implemented for all relations that could be N+1
  □ Query depth limiting enabled
  □ Query complexity scoring implemented
  □ Rate limiting applied (per IP or per token)
  □ Introspection disabled in production (unless there's a reason)

CACHING:
  □ Persisted queries implemented for production
  □ Resolver-level caching for rarely changing data
  □ Cache invalidation strategy thought through

SCHEMA EVOLUTION:
  □ Deprecation used before removing fields
  □ Field usage monitored before removing
  □ Nullable fields for new optional fields
  □ Input types make adding new parameters easy

MONITORING:
  □ Latency per operation monitored (not just per endpoint)
  □ Resolver latency monitored to find bottlenecks
  □ Error rate per operation monitored
  □ Field usage metrics available for schema governance

ERROR HANDLING:
  □ Error extensions contain machine-readable codes
  □ Union types used for expected errors (not just null)
  □ Partial success clearly documented

Summary #

  • GraphQL was born to solve over-fetching and under-fetching — not to replace REST. Use it if there’s a real problem justifying its added complexity.
  • The schema is the contract — design for the domain, not the database — field names reflect the business, explicit relations (not FKs), enums for limited values, input types for mutations.
  • The N+1 query problem is the biggest threat in production — DataLoader must be implemented for every relation before going to production. Without it, one simple query can produce hundreds of database queries.
  • Query complexity limiting isn’t an optimization, it’s basic security — depth limits and complexity scoring must exist before the API is exposed. One uncontrolled query can collapse the service.
  • Caching in GraphQL needs a different strategy — persisted queries for CDN caching, resolver caching with Redis, and client-side normalized caching with Apollo/Relay.
  • Schemas evolve through deprecation, not versioning — add new fields, mark old fields deprecated, monitor usage, then remove once nobody uses them.
  • Monitor per operation, not per endpoint — one /graphql endpoint hides hundreds of different operations. Use tooling that breaks down per operation.
  • A GraphQL + REST/gRPC hybrid is often the healthiest pattern — GraphQL as the BFF layer facing clients, internal services staying on REST or gRPC.
  • GraphQL isn’t an ORM over HTTP — resolvers must apply business logic, validation, and authorization. Schemas too close to the database violate abstraction.
  • Disable introspection in production — a queryable schema is a map for attackers wanting to find sensitive fields or plan complex DoS queries.
#

← Previous: REST   Next: gRPC

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact