Skip to content
Low Level Design Mastery Logo
LowLevelDesign Mastery

GraphQL Fundamentals

Ask for exactly what you need, get exactly that

GraphQL is a query language for APIs that lets clients request exactly the data they need. Unlike REST where you get fixed responses, GraphQL gives you the power to shape your queries.

Diagram
  1. Single Endpoint - One URL for all operations
  2. Client-Specified Queries - Client decides what data to fetch
  3. Strongly Typed Schema - Schema defines all possible queries
  4. Introspection - Schema is self-documenting
  5. Real-time Subscriptions - WebSocket support for live updates

GET /users/123 → Returns full user object
GET /users/123/orders → Returns all orders
GET /users/123/profile → Returns full profile

Problems:

  • ❌ Over-fetching (get data you don’t need)
  • ❌ Under-fetching (need multiple requests)
  • ❌ Multiple round trips

GraphQL: Single Endpoint, Flexible Queries

Section titled “GraphQL: Single Endpoint, Flexible Queries”
query {
user(id: 123) {
name
email
orders {
id
total
}
}
}

Benefits:

  • ✅ Fetch exactly what you need
  • ✅ Get related data in one request
  • ✅ Single round trip

Schema defines what data is available and how to query it.

type User {
id: ID!
name: String!
email: String!
orders: [Order!]!
}
type Order {
id: ID!
total: Float!
items: [OrderItem!]!
}
type Query {
user(id: ID!): User
users: [User!]!
}
type Mutation {
createUser(name: String!, email: String!): User!
updateUser(id: ID!, name: String): User!
}
TypeMeaningExample
StringText"John Doe"
IntInteger42
FloatDecimal99.99
BooleanTrue/Falsetrue
IDUnique identifier"123"
!Required (non-null)String!
[Type]Array[String!]!

Queries are for reading data. They’re like GET requests in REST.

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

Response:

{
"data": {
"user": {
"name": "John Doe",
"email": "[email protected]"
}
}
}
query {
users(limit: 10, offset: 0) {
id
name
}
}
query {
user(id: "123") {
name
email
orders {
id
total
items {
product {
name
price
}
quantity
}
}
}
}

This single query replaces multiple REST calls:

  • GET /users/123
  • GET /users/123/orders
  • GET /orders/456/items
  • GET /products/789

Get multiple versions of same field:

query {
user1: user(id: "123") {
name
}
user2: user(id: "456") {
name
}
}

Reusable field sets:

fragment UserInfo on User {
id
name
email
}
query {
user(id: "123") {
...UserInfo
orders {
id
}
}
}

Mutations are for creating, updating, or deleting data. Like POST/PUT/DELETE in REST.

mutation {
createUser(name: "Jane Doe", email: "[email protected]") {
id
name
email
}
}

Response:

{
"data": {
"createUser": {
"id": "456",
"name": "Jane Doe",
"email": "[email protected]"
}
}
}
mutation {
updateUser(id: "123", name: "John Smith") {
id
name
email
}
}
mutation {
deleteUser(id: "123") {
id
}
}

Execute multiple mutations in one request:

mutation {
createUser(name: "Alice", email: "[email protected]") {
id
}
createOrder(userId: "123", items: [...]) {
id
}
}

Subscriptions provide real-time data using WebSockets.

subscription {
userUpdated(userId: "123") {
id
name
email
}
}

How it works:

  1. Client subscribes via WebSocket
  2. Server sends updates when data changes
  3. Client receives real-time updates
  • Live chat messages
  • Real-time notifications
  • Stock price updates
  • Collaborative editing
  • Live dashboards

The biggest performance issue in GraphQL.

query {
users {
name
orders { # N+1 problem!
id
total
}
}
}

What happens:

  1. Query 1: SELECT * FROM users (gets 100 users)
  2. Query 2: SELECT * FROM orders WHERE user_id = 1
  3. Query 3: SELECT * FROM orders WHERE user_id = 2
  4. … (100 more queries!)

Total: 1 + 100 = 101 queries! 😱

DataLoader batches requests:

Diagram

How DataLoader works:

  1. Collects all requests in a batch
  2. Waits for next event loop tick
  3. Executes single batched query
  4. Distributes results to individual requests
"dataloader_example.py
from dataloader import DataLoader
import asyncio
# Create DataLoader for orders
order_loader = DataLoader(
batch_load_fn=lambda user_ids: load_orders_for_users(user_ids)
)
async def get_user_with_orders(user_id):
user = await get_user(user_id)
# This will be batched!
orders = await order_loader.load(user_id)
return {
"user": user,
"orders": orders
}
async def load_orders_for_users(user_ids):
# Single query for all users
orders = await db.query(
"SELECT * FROM orders WHERE user_id IN ?",
user_ids
)
# Group by user_id
orders_by_user = {}
for order in orders:
if order.user_id not in orders_by_user:
orders_by_user[order.user_id] = []
orders_by_user[order.user_id].append(order)
# Return in same order as requested
return [orders_by_user.get(uid, []) for uid in user_ids]

Resolvers are functions that fetch data for each field.

"resolvers.py
from ariadne import QueryType, MutationType
from typing import Optional, List
query = QueryType()
mutation = MutationType()
@query.field("user")
def resolve_user(_, info, id: str) -> Optional[dict]:
"""Resolver for user query"""
return user_repository.find_by_id(id)
@query.field("users")
def resolve_users(_, info) -> List[dict]:
"""Resolver for users query"""
return user_repository.find_all()
@mutation.field("createUser")
def resolve_create_user(_, info, name: str, email: str) -> dict:
"""Resolver for createUser mutation"""
user = user_repository.create(name=name, email=email)
return user
# Field resolvers (for nested fields)
def resolve_user_orders(user: dict, info) -> List[dict]:
"""Resolver for User.orders field"""
return order_repository.find_by_user_id(user["id"])

  • Multiple clients with different data needs
  • Mobile apps where bandwidth matters
  • Complex relationships between data
  • Rapidly evolving API requirements
  • Real-time updates needed (subscriptions)
  • Simple CRUD operations
  • Caching is critical (HTTP caching)
  • File uploads (GraphQL handles this poorly)
  • Simple APIs where over-fetching isn’t a problem
  • Existing REST infrastructure

❌ Bad:

query {
users { # Could return millions!
id
name
}
}

✅ Good:

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

Prevent deeply nested queries:

MAX_QUERY_DEPTH = 10
def validate_query_depth(query, max_depth=MAX_QUERY_DEPTH):
depth = calculate_depth(query)
if depth > max_depth:
raise GraphQLError("Query too deep")

Authorize at field level:

@query.field("user")
def resolve_user(_, info, id: str):
user = user_repository.find_by_id(id)
# Check if user can access email field
if not can_access_field(info, "email"):
user.pop("email") # Remove email from response
return user

Prevent expensive queries:

def calculate_complexity(query):
complexity = 0
for field in query.fields:
complexity += field.complexity
if field.has_list:
complexity *= field.list_size
return complexity
if calculate_complexity(query) > MAX_COMPLEXITY:
raise GraphQLError("Query too complex")

Disable introspection in production (or limit it):

# Disable introspection
schema = make_executable_schema(type_defs, resolvers)
schema.introspection = False # In production

At the code level, GraphQL translates to resolvers, schema definitions, and DataLoader patterns.

"graphql_service.py
from ariadne import make_executable_schema, QueryType, MutationType
from dataloader import DataLoader
# Schema definition
type_defs = """
type User {
id: ID!
name: String!
email: String!
orders: [Order!]!
}
type Order {
id: ID!
total: Float!
items: [OrderItem!]!
}
type Query {
user(id: ID!): User
users: [User!]!
}
type Mutation {
createUser(name: String!, email: String!): User!
}
"""
query = QueryType()
mutation = MutationType()
# DataLoaders
order_loader = DataLoader(
batch_load_fn=lambda user_ids: load_orders_batch(user_ids)
)
@query.field("user")
def resolve_user(_, info, id: str):
return user_repository.find_by_id(id)
@query.field("users")
def resolve_users(_, info):
return user_repository.find_all()
@mutation.field("createUser")
def resolve_create_user(_, info, name: str, email: str):
return user_repository.create(name=name, email=email)
# Field resolver with DataLoader
def resolve_user_orders(user, info):
return order_loader.load(user["id"])
schema = make_executable_schema(type_defs, [query, mutation])

🎯 Ask for What You Need

GraphQL lets clients specify exactly what data they need, reducing over-fetching and under-fetching.

⚡ Watch for N+1

The N+1 query problem is GraphQL’s biggest performance issue. Use DataLoader to batch requests.

🔄 Resolvers Fetch Data

Resolvers are functions that fetch data for each field. They’re where your business logic lives.

📊 Schema is Contract

GraphQL schema defines your API contract. It’s self-documenting and strongly typed.