TECHNICAL PRESENTATION

Introduction to
GraphQL

A Query Language for Your APIs
schema · queries · mutations · subscriptions · resolvers · Apollo
02

Agenda

Foundations

  • What is GraphQL & its origin
  • GraphQL vs REST
  • Schema Definition Language (SDL)
  • Queries, Mutations & Subscriptions

Server-Side

  • Resolvers & the resolver chain
  • Apollo Server with Express
  • DataLoaders & the N+1 problem
  • Authentication & authorisation

Client-Side

  • Apollo Client & React integration
  • Caching & optimistic updates
  • Error handling strategies
  • Pagination patterns

Production

  • Schema design patterns
  • Performance & security
  • Testing GraphQL APIs
  • Summary & next steps
03

What Is GraphQL?

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.

Core Principles

  • Declarative data fetching — client specifies the shape
  • Strongly typed — schema defines every field and type
  • Single endpoint — one URL, one POST
  • Introspective — clients can query the schema itself
# 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" }
      ]
    }
  }
}
04

GraphQL vs REST

AspectRESTGraphQL
EndpointsMultiple (/users, /users/:id/posts)Single (/graphql)
Data fetchingServer decides response shapeClient specifies exact fields
Over-fetchingCommon — returns entire resourceEliminated — request only what you need
Under-fetchingRequires multiple round-tripsSingle request traverses the graph
N+1 RequestsClient may chain GET callsResolved server-side with DataLoader
Versioning/api/v1, /api/v2Schema evolution, deprecation directives
CachingHTTP caching (ETags, 304)Normalised client cache (Apollo, Relay)
ContractOpenAPI / Swagger (opt-in)Schema-first — always present

When REST Wins

  • Simple CRUD with uniform resource shapes
  • Heavy reliance on HTTP caching (CDN edge)
  • File uploads (though GraphQL multipart spec exists)
  • Team unfamiliarity with GraphQL tooling

When GraphQL Wins

  • Multiple clients needing different data shapes (web, mobile, IoT)
  • Deeply nested or interconnected data (social graphs)
  • Rapid frontend iteration without backend changes
  • Microservice aggregation via federation
05

Schema Definition Language (SDL)

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!
}

Non-Null Rules

  • String — nullable field, may return null
  • String! — never null, server guarantees a value
  • [String!]! — non-null list of non-null strings
  • [String]! — non-null list, but items may be null
06

Queries

Field Selection & Nesting

# Only request the fields you need
query {
  user(id: "42") {
    name
    email
    posts {
      title
      tags
    }
  }
}

Arguments

# Arguments can appear on any field
query {
  users(limit: 5, offset: 10) {
    name
    posts(status: PUBLISHED) {
      title
    }
  }
}

Aliases

Rename fields to avoid conflicts when querying the same field with different arguments.

query {
  admins: users(role: ADMIN) {
    name
  }
  editors: users(role: EDITOR) {
    name
  }
}

Fragments

Reusable field selections — DRY for repeated structures.

fragment UserFields on User {
  id
  name
  email
  role
}

query {
  user(id: "42") { ...UserFields }
  me { ...UserFields }
}
07

Mutations

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.

Create

mutation {
  createUser(input: {
    name: "Alice"
    email: "alice@example.com"
    role: EDITOR
  }) {
    id
    name
    createdAt
  }
}

Update

mutation {
  updateUser(id: "42", input: {
    name: "Alice Smith"
    email: "alice.smith@example.com"
  }) {
    id
    name
    email
  }
}

Delete

mutation {
  deleteUser(id: "42")
}

Variables & Operation Names

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" } }

Mutation Design Tips

  • Use input types for complex arguments
  • Return the mutated object so the client cache updates
  • Consider a payload type with errors field for domain errors
08

Subscriptions

Subscriptions provide real-time, push-based updates from server to client. They use a persistent connection (typically WebSocket) instead of request-response.

Schema

type Subscription {
  postPublished: Post!
  commentAdded(postId: ID!): Comment!
  userStatusChanged: User!
}

Client Usage

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

Transport Protocols

  • graphql-ws — modern protocol (recommended)
  • subscriptions-transport-ws — legacy, deprecated
  • Server-Sent Events — HTTP-based alternative

Server Implementation (Apollo)

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}`]),
    },
  },
};
09

Resolvers

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);
    },
  },
};

The Four Arguments

ArgPurpose
parentResult from the parent resolver
argsArguments passed to the field
contextShared per-request state (db, auth, loaders)
infoAST of the query, field name, schema metadata

Resolver Chain

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.

Default Resolver

If no resolver is defined for a field, GraphQL reads parent[fieldName]. This is why returning plain objects with matching property names "just works".

10

Apollo Server with Express

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();

Key Components

  • typeDefs — your SDL schema string or file
  • resolvers — map of type → field → function
  • context — built per-request, shared across resolvers
  • expressMiddleware — plugs Apollo into Express

Apollo Sandbox

Apollo Server 4 ships with Apollo Sandbox — an interactive IDE at your endpoint URL. Features: schema explorer, query history, response tracing, and documentation.

Context Best Practices

  • Create DataLoader instances per request
  • Authenticate the user and attach to context
  • Pass database connections / pools
  • Never store mutable state between requests

Alternatives

  • Yoga — by The Guild, spec-compliant
  • Mercurius — Fastify-native
  • Pothos — code-first schema builder
11

Apollo Client

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.

Setup

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>

useQuery

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>
  );
}

useMutation

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];
            },
          },
        });
      },
    }
  );
}

Optimistic Updates

Pass optimisticResponse to useMutation to instantly reflect changes in the UI before the server responds. If the mutation fails, the cache rolls back automatically.

12

DataLoaders & the N+1 Problem

The N+1 Problem

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)
    }
  }
}

The Solution: DataLoader

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),
  },
};

Critical Rules

  • Create loaders per-request — never share across requests (stale cache, auth leaks)
  • Batch function must return results in same order as keys
  • DataLoader deduplicates — loading the same ID twice hits DB once
  • Use { cache: false } to disable per-request caching if needed
13

Authentication & Authorisation

Context-Based Auth

// 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() };
  },
}));

Resolver-Level Checks

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();
    },
  },
};

Directive-Based Auth

# 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;
    },
  });
}

Field-Level Permissions

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.

14

Error Handling

GraphQL Error Format

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 }
      }
    }
  ]
}

Partial Responses

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.

Custom Error Classes

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);

Error Codes Convention

  • UNAUTHENTICATED — no valid credentials
  • FORBIDDEN — insufficient permissions
  • NOT_FOUND — resource does not exist
  • VALIDATION_ERROR — invalid input
  • INTERNAL_SERVER_ERROR — unexpected failures
15

Pagination

Offset Pagination (simple)

type Query {
  posts(limit: Int = 10, offset: Int = 0): [Post!]!
}

# Usage: posts(limit: 10, offset: 20)
# Problem: adding/removing items shifts pages

Cursor-Based Pagination (recommended)

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
}

Resolver Implementation

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(),
      };
    },
  },
};

Client Usage

const { data, fetchMore } = useQuery(GET_POSTS, {
  variables: { first: 10 },
});

// Load next page
fetchMore({
  variables: { after: data.posts.pageInfo.endCursor },
});
16

Schema Design Patterns

Interfaces

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!
}

Nullable vs Non-Null

  • Non-null by default for fields you always return
  • Nullable when data may not exist (optional relations)
  • Caution: non-null errors bubble up to the nearest nullable parent — too aggressive nullability can wipe entire responses

Union Types

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 } }
  }
}

Mutation Payload Pattern

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!
}
17

Performance

The Threat: Abusive Queries

# A malicious deeply-nested query
query Evil {
  user(id: "1") {
    posts {
      author {
        posts {
          author {
            posts { title }  # 6 levels deep
          }
        }
      }
    }
  }
}

Query Complexity Analysis

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 }),
      ],
    }),
  ],
});

Depth Limiting

const depthLimit = require('graphql-depth-limit');

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [depthLimit(7)],
});

Persisted Queries

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" }
}

Caching Strategies

  • CDN / HTTP cache — use @cacheControl directive
  • Response cache — Apollo Server response cache plugin
  • DataLoader — per-request deduplication
  • Redis — shared cache across instances
  • Apollo Client — normalised in-memory cache
18

Testing

Mocking Resolvers

const { 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,
});

Schema Validation

const { validateSchema, buildSchema } = require('graphql');

test('schema is valid', () => {
  const schema = buildSchema(typeDefs);
  const errors = validateSchema(schema);
  expect(errors).toHaveLength(0);
});

Integration Testing

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');
  });
});
19

Summary & Next Steps

Key Takeaways

  • GraphQL = typed schema + declarative data fetching
  • Single endpoint eliminates over/under-fetching
  • SDL is the contract — schema-first design
  • Resolvers map fields to data sources
  • DataLoader solves the N+1 problem via batching
  • Apollo Server + Client is the dominant ecosystem
  • Cursor-based pagination with Relay Connection spec
  • Protect with depth limiting, complexity analysis, persisted queries

Recommended Reading

  • graphql.org — official specification and docs
  • apollographql.com/docs — Apollo Server & Client
  • Production Ready GraphQL — Marc-André Giroux
  • Learning GraphQL — Eve Porcello & Alex Banks

Try These

  • Build a full-stack app with Apollo Server + React
  • Add real-time features with subscriptions
  • Implement cursor pagination with a real database
  • Set up schema federation across microservices
  • Explore code-first schemas with Pothos or Nexus

Thank you! — Built with Reveal.js · Single self-contained HTML file