GraphQL is an open-source query language for APIs and a runtime for executing those queries against a type system you define. Created at Facebook in 2012, open-sourced in 2015, and now governed by the GraphQL Foundation under the Linux Foundation.
Unlike REST, GraphQL exposes a single endpoint. Clients describe exactly the data they need, and the server returns precisely that shape — nothing more, nothing less.
# Client sends exactly the query it needs
query {
user(id: "42") {
name
email
posts(last: 3) {
title
createdAt
}
}
}
// Server returns exactly that shape
{
"data": {
"user": {
"name": "Alice",
"email": "alice@example.com",
"posts": [
{ "title": "GraphQL 101", "createdAt": "2025-12-01" },
{ "title": "SDL Deep Dive", "createdAt": "2025-11-15" },
{ "title": "Resolver Tips", "createdAt": "2025-11-02" }
]
}
}
}
| Aspect | REST | GraphQL |
|---|---|---|
| Endpoints | Multiple (/users, /users/:id/posts) | Single (/graphql) |
| Data fetching | Server decides response shape | Client specifies exact fields |
| Over-fetching | Common — returns entire resource | Eliminated — request only what you need |
| Under-fetching | Requires multiple round-trips | Single request traverses the graph |
| N+1 Requests | Client may chain GET calls | Resolved server-side with DataLoader |
| Versioning | /api/v1, /api/v2 | Schema evolution, deprecation directives |
| Caching | HTTP caching (ETags, 304) | Normalised client cache (Apollo, Relay) |
| Contract | OpenAPI / Swagger (opt-in) | Schema-first — always present |
The schema is the contract between client and server. SDL defines every type, field, argument, and relationship in a human-readable syntax.
# Scalar types: String, Int, Float, Boolean, ID
# ! = non-null, [] = list
type User {
id: ID!
name: String!
email: String!
role: Role!
posts: [Post!]!
createdAt: DateTime!
}
type Post {
id: ID!
title: String!
body: String!
author: User!
tags: [String!]!
status: PostStatus!
}
enum Role {
ADMIN
EDITOR
VIEWER
}
enum PostStatus {
DRAFT
PUBLISHED
ARCHIVED
}
scalar DateTime
# Input types for mutations
input CreateUserInput {
name: String!
email: String!
role: Role = VIEWER # default value
}
input PostFilterInput {
status: PostStatus
authorId: ID
tag: String
}
# Root types — entry points
type Query {
user(id: ID!): User
users(limit: Int = 10, offset: Int = 0): [User!]!
posts(filter: PostFilterInput): [Post!]!
}
type Mutation {
createUser(input: CreateUserInput!): User!
updateUser(id: ID!, input: CreateUserInput!): User!
deleteUser(id: ID!): Boolean!
}
type Subscription {
postPublished: Post!
}
String — nullable field, may return nullString! — never null, server guarantees a value[String!]! — non-null list of non-null strings[String]! — non-null list, but items may be null# Only request the fields you need
query {
user(id: "42") {
name
email
posts {
title
tags
}
}
}
# Arguments can appear on any field
query {
users(limit: 5, offset: 10) {
name
posts(status: PUBLISHED) {
title
}
}
}
Rename fields to avoid conflicts when querying the same field with different arguments.
query {
admins: users(role: ADMIN) {
name
}
editors: users(role: EDITOR) {
name
}
}
Reusable field selections — DRY for repeated structures.
fragment UserFields on User {
id
name
email
role
}
query {
user(id: "42") { ...UserFields }
me { ...UserFields }
}
Mutations are the GraphQL equivalent of POST/PUT/DELETE. They modify server-side data and return a result that the client can query fields from.
mutation {
createUser(input: {
name: "Alice"
email: "alice@example.com"
role: EDITOR
}) {
id
name
createdAt
}
}
mutation {
updateUser(id: "42", input: {
name: "Alice Smith"
email: "alice.smith@example.com"
}) {
id
name
email
}
}
mutation {
deleteUser(id: "42")
}
Production clients always use variables — never string interpolation.
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
name
}
}
# Variables (JSON):
# { "input": { "name": "Bob", "email": "bob@co.io" } }
errors field for domain errorsSubscriptions provide real-time, push-based updates from server to client. They use a persistent connection (typically WebSocket) instead of request-response.
type Subscription {
postPublished: Post!
commentAdded(postId: ID!): Comment!
userStatusChanged: User!
}
subscription OnNewPost {
postPublished {
id
title
author {
name
}
}
}
const { PubSub } = require('graphql-subscriptions');
const pubsub = new PubSub();
const resolvers = {
Mutation: {
createPost: async (_, { input }, ctx) => {
const post = await ctx.db.posts.create(input);
// Publish event to all subscribers
pubsub.publish('POST_PUBLISHED', {
postPublished: post,
});
return post;
},
},
Subscription: {
postPublished: {
subscribe: () =>
pubsub.asyncIterableIterator(['POST_PUBLISHED']),
},
commentAdded: {
subscribe: (_, { postId }) =>
pubsub.asyncIterableIterator([`COMMENT_${postId}`]),
},
},
};
Resolvers are functions that produce the value for each field in the schema. Every field has a resolver — if you don't write one, the default resolver reads the property from the parent object.
const resolvers = {
Query: {
// resolver(parent, args, context, info)
user: async (_, { id }, ctx) => {
return ctx.db.users.findById(id);
},
users: async (_, { limit, offset }, ctx) => {
return ctx.db.users.findAll({ limit, offset });
},
},
User: {
// Field-level resolver — runs for each User
posts: async (parent, _, ctx) => {
return ctx.db.posts.findByAuthor(parent.id);
},
fullName: (parent) => {
return `${parent.firstName} ${parent.lastName}`;
},
},
Post: {
author: async (parent, _, ctx) => {
return ctx.db.users.findById(parent.authorId);
},
},
};
| Arg | Purpose |
|---|---|
| parent | Result from the parent resolver |
| args | Arguments passed to the field |
| context | Shared per-request state (db, auth, loaders) |
| info | AST of the query, field name, schema metadata |
GraphQL executes resolvers top-down. A Query.user resolver returns a User object, then each requested field on that User triggers its own resolver. The chain continues until all scalar leaves are resolved.
If no resolver is defined for a field, GraphQL reads parent[fieldName]. This is why returning plain objects with matching property names "just works".
const express = require('express');
const { ApolloServer } = require('@apollo/server');
const {
expressMiddleware
} = require('@apollo/server/express4');
const cors = require('cors');
const typeDefs = `#graphql
type Query {
hello: String!
users: [User!]!
}
type User {
id: ID!
name: String!
email: String!
}
`;
const resolvers = {
Query: {
hello: () => 'Hello, GraphQL!',
users: async (_, __, ctx) => ctx.db.users.findAll(),
},
};
async function startServer() {
const app = express();
const server = new ApolloServer({ typeDefs, resolvers });
await server.start();
app.use(
'/graphql',
cors(),
express.json(),
expressMiddleware(server, {
context: async ({ req }) => ({
db: require('./db'),
user: await authenticate(req),
}),
})
);
app.listen(4000, () =>
console.log('GraphQL at http://localhost:4000/graphql')
);
}
startServer();
Apollo Server 4 ships with Apollo Sandbox — an interactive IDE at your endpoint URL. Features: schema explorer, query history, response tracing, and documentation.
Apollo Client is the most popular GraphQL client for React. It provides hooks for queries and mutations, a normalised in-memory cache, and optimistic UI updates.
import { ApolloClient, InMemoryCache, ApolloProvider }
from '@apollo/client';
const client = new ApolloClient({
uri: 'http://localhost:4000/graphql',
cache: new InMemoryCache(),
});
// Wrap your app
<ApolloProvider client={client}>
<App />
</ApolloProvider>
import { useQuery, gql } from '@apollo/client';
const GET_USERS = gql`
query GetUsers {
users { id name email }
}
`;
function UserList() {
const { loading, error, data } = useQuery(GET_USERS);
if (loading) return <Spinner />;
if (error) return <Error msg={error.message} />;
return data.users.map(u =>
<div key={u.id}>{u.name}</div>
);
}
const CREATE_USER = gql`
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) { id name email }
}
`;
function CreateUserForm() {
const [createUser, { loading }] = useMutation(
CREATE_USER,
{
// Update cache after mutation
update(cache, { data: { createUser } }) {
cache.modify({
fields: {
users(existing = []) {
const newRef = cache.writeFragment({
data: createUser,
fragment: gql`fragment NewUser on User {
id name email
}`,
});
return [...existing, newRef];
},
},
});
},
}
);
}
Pass optimisticResponse to useMutation to instantly reflect changes in the UI before the server responds. If the mutation fails, the cache rolls back automatically.
Querying a list of 50 posts, each with an author, fires 1 query for posts + 50 individual queries for authors = 51 queries. This pattern kills database performance.
# This innocent query causes N+1
query {
posts { # 1 query: SELECT * FROM posts
title
author { # N queries: SELECT * FROM users WHERE id = ?
name # (one per post)
}
}
}
Facebook's dataloader library batches individual loads into a single query and caches results within a request.
npm install dataloader
const DataLoader = require('dataloader');
// Batch function: receives array of keys,
// must return array of results in same order
const userLoader = new DataLoader(async (ids) => {
// 1 query instead of N
const users = await db.query(
'SELECT * FROM users WHERE id = ANY($1)',
[ids]
);
// Map results to match input order
const userMap = new Map(users.map(u => [u.id, u]));
return ids.map(id => userMap.get(id) || null);
});
// In resolver
const resolvers = {
Post: {
author: (post, _, ctx) =>
ctx.loaders.user.load(post.authorId),
},
};
{ cache: false } to disable per-request caching if needed// Extract user from JWT in context factory
const server = new ApolloServer({ typeDefs, resolvers });
app.use('/graphql', expressMiddleware(server, {
context: async ({ req }) => {
const token = req.headers.authorization?.split(' ')[1];
let user = null;
if (token) {
try {
user = jwt.verify(token, process.env.JWT_SECRET);
} catch (e) {
// Token invalid or expired
}
}
return { user, db, loaders: createLoaders() };
},
}));
const resolvers = {
Query: {
adminDashboard: (_, __, ctx) => {
if (!ctx.user) throw new GraphQLError(
'Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' }
});
if (ctx.user.role !== 'ADMIN')
throw new GraphQLError(
'Not authorised', {
extensions: { code: 'FORBIDDEN' }
});
return ctx.db.dashboard.getStats();
},
},
};
# Schema directives for declarative permissions
directive @auth(requires: Role!) on FIELD_DEFINITION
type Query {
publicPosts: [Post!]!
adminDashboard: Dashboard! @auth(requires: ADMIN)
myProfile: User! @auth(requires: VIEWER)
}
// Directive transformer (Apollo Server 4)
function authDirective(schema) {
return mapSchema(schema, {
[MapperKind.OBJECT_FIELD]: (fieldConfig) => {
const authDir = getDirective(schema, fieldConfig, 'auth');
if (authDir) {
const { requires } = authDir[0];
const { resolve = defaultFieldResolver } = fieldConfig;
fieldConfig.resolve = (parent, args, ctx, info) => {
if (!ctx.user) throw new GraphQLError('Unauthenticated');
if (ctx.user.role !== requires)
throw new GraphQLError('Forbidden');
return resolve(parent, args, ctx, info);
};
}
return fieldConfig;
},
});
}
Hide sensitive fields (email, salary) based on the caller's role. Return null or throw for unauthorised field access — the rest of the query still resolves.
GraphQL always returns HTTP 200. Errors appear in the errors array alongside any partial data.
{
"data": {
"user": {
"name": "Alice",
"secretField": null
}
},
"errors": [
{
"message": "Not authorised to view secretField",
"locations": [{ "line": 4, "column": 5 }],
"path": ["user", "secretField"],
"extensions": {
"code": "FORBIDDEN",
"http": { "status": 403 }
}
}
]
}
Unlike REST, a GraphQL response can include both data and errors. Nullable fields resolve to null on error; non-null errors bubble up to the nearest nullable parent.
const { GraphQLError } = require('graphql');
class NotFoundError extends GraphQLError {
constructor(resource, id) {
super(`${resource} ${id} not found`, {
extensions: {
code: 'NOT_FOUND',
http: { status: 404 },
},
});
}
}
class ValidationError extends GraphQLError {
constructor(fields) {
super('Validation failed', {
extensions: {
code: 'VALIDATION_ERROR',
fields, // { email: 'Invalid format' }
},
});
}
}
// Usage in resolvers
const user = await ctx.db.users.findById(id);
if (!user) throw new NotFoundError('User', id);
UNAUTHENTICATED — no valid credentialsFORBIDDEN — insufficient permissionsNOT_FOUND — resource does not existVALIDATION_ERROR — invalid inputINTERNAL_SERVER_ERROR — unexpected failurestype Query {
posts(limit: Int = 10, offset: Int = 0): [Post!]!
}
# Usage: posts(limit: 10, offset: 20)
# Problem: adding/removing items shifts pages
Uses an opaque cursor (encoded ID or timestamp) as a bookmark. Stable under insertions/deletions.
# Relay Connection Specification
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
}
const resolvers = {
Query: {
posts: async (_, { first = 10, after }, ctx) => {
const limit = Math.min(first, 100); // cap
const cursor = after
? Buffer.from(after, 'base64').toString()
: null;
const rows = await ctx.db.query(
`SELECT * FROM posts
${cursor ? 'WHERE id > $2' : ''}
ORDER BY id ASC
LIMIT $1`,
cursor ? [limit + 1, cursor] : [limit + 1]
);
const hasNextPage = rows.length > limit;
const edges = rows.slice(0, limit).map(row => ({
node: row,
cursor: Buffer.from(row.id.toString())
.toString('base64'),
}));
return {
edges,
pageInfo: {
hasNextPage,
hasPreviousPage: !!after,
startCursor: edges[0]?.cursor,
endCursor: edges[edges.length - 1]?.cursor,
},
totalCount: await ctx.db.posts.count(),
};
},
},
};
const { data, fetchMore } = useQuery(GET_POSTS, {
variables: { first: 10 },
});
// Load next page
fetchMore({
variables: { after: data.posts.pageInfo.endCursor },
});
Share fields across types. Useful when different types have a common base.
interface Node {
id: ID!
}
interface Timestamped {
createdAt: DateTime!
updatedAt: DateTime!
}
type User implements Node & Timestamped {
id: ID!
name: String!
createdAt: DateTime!
updatedAt: DateTime!
}
type Post implements Node & Timestamped {
id: ID!
title: String!
createdAt: DateTime!
updatedAt: DateTime!
}
A field can return one of several unrelated types.
union SearchResult = User | Post | Comment
type Query {
search(term: String!): [SearchResult!]!
}
# Client uses inline fragments
query {
search(term: "graphql") {
... on User { name email }
... on Post { title body }
... on Comment { text author { name } }
}
}
Return a dedicated payload type instead of the raw object.
type CreateUserPayload {
user: User
errors: [UserError!]!
}
type UserError {
field: String!
message: String!
}
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
}
# A malicious deeply-nested query
query Evil {
user(id: "1") {
posts {
author {
posts {
author {
posts { title } # 6 levels deep
}
}
}
}
}
}
Assign a cost to each field. Reject queries exceeding a threshold.
const { createComplexityRule } = require(
'graphql-query-complexity'
);
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [
createComplexityRule({
maximumComplexity: 1000,
estimators: [
fieldExtensionsEstimator(),
simpleEstimator({ defaultComplexity: 1 }),
],
}),
],
});
const depthLimit = require('graphql-depth-limit');
const server = new ApolloServer({
typeDefs,
resolvers,
validationRules: [depthLimit(7)],
});
Pre-register allowed queries at build time. The client sends a hash instead of the full query string. Blocks arbitrary queries in production.
// Client sends:
{ "extensions": {
"persistedQuery": {
"sha256Hash": "abc123...",
"version": 1
}
},
"variables": { "id": "42" }
}
@cacheControl directiveconst { addMocksToSchema } = require(
'@graphql-tools/mock'
);
const { makeExecutableSchema } = require(
'@graphql-tools/schema'
);
const schema = makeExecutableSchema({ typeDefs });
const mocks = {
User: () => ({
id: () => faker.string.uuid(),
name: () => faker.person.fullName(),
email: () => faker.internet.email(),
}),
DateTime: () => new Date().toISOString(),
};
const mockedSchema = addMocksToSchema({
schema,
mocks,
});
const { validateSchema, buildSchema } = require('graphql');
test('schema is valid', () => {
const schema = buildSchema(typeDefs);
const errors = validateSchema(schema);
expect(errors).toHaveLength(0);
});
const { ApolloServer } = require('@apollo/server');
const assert = require('assert');
describe('User queries', () => {
let server;
beforeAll(() => {
server = new ApolloServer({
typeDefs,
resolvers,
});
});
test('fetches user by ID', async () => {
const res = await server.executeOperation({
query: `query GetUser($id: ID!) {
user(id: $id) { id name email }
}`,
variables: { id: '42' },
}, {
contextValue: {
db: mockDb,
loaders: createLoaders(mockDb),
},
});
expect(res.body.singleResult.errors).toBeUndefined();
const user = res.body.singleResult.data.user;
expect(user.name).toBe('Alice');
expect(user.email).toBe('alice@example.com');
});
test('returns error for missing user', async () => {
const res = await server.executeOperation({
query: `query { user(id: "999") { id name } }`,
}, { contextValue: { db: mockDb, loaders: createLoaders(mockDb) } });
expect(res.body.singleResult.errors).toBeDefined();
expect(res.body.singleResult.errors[0]
.extensions.code).toBe('NOT_FOUND');
});
});
Thank you! — Built with Reveal.js · Single self-contained HTML file