Implementation Guide

GraphQL API Implementation

Design and build a production GraphQL API with Apollo Server — covering schema design, resolvers, DataLoader optimisation, real-time subscriptions, security, and testing.

45 min read
Advanced
Updated 2025
GraphQL Apollo Schema Design Subscriptions
1

GraphQL Fundamentals

GraphQL is a query language for APIs and a runtime for executing those queries. Clients specify exactly what data they need — no more over-fetching (REST returning unused fields) or under-fetching (multiple REST round-trips for related data).

graphqlThe three root operation types
# Query – read data
query GetUser($id: ID!) {
  user(id: $id) {
    id
    name
    email
    posts(limit: 5) {     # nested, no extra round-trip
      title
      publishedAt
    }
  }
}

# Mutation – write data
mutation CreatePost($input: CreatePostInput!) {
  createPost(input: $input) {
    id
    title
    createdAt
  }
}

# Subscription – real-time push via WebSocket
subscription OnNewPost($authorId: ID!) {
  postCreated(authorId: $authorId) {
    id
    title
    author { name }
  }
}

Every GraphQL API has a single endpoint (typically /graphql). The server validates each query against its type-safe schema before execution, providing self-documenting APIs via introspection.

GraphQL vs REST

GraphQL excels when clients have diverse data needs (mobile vs web), when data is highly relational, or when you're building a public API with many consumers. REST is simpler for simple CRUD, caching-heavy scenarios (HTTP caching works at the URL level), or file uploads.

2

Schema Design

The schema is the contract between server and clients. Design it around your UI requirements and business domain, not your database schema. Use SDL (Schema Definition Language) for clear, version-controllable type definitions.

graphqlschema.graphql
scalar DateTime
scalar URL

# Interfaces allow polymorphism
interface Node {
  id: ID!
}

interface Timestamped {
  createdAt: DateTime!
  updatedAt: DateTime!
}

type User implements Node & Timestamped {
  id:        ID!
  name:      String!
  email:     String!
  avatar:    URL
  role:      UserRole!
  posts(
    limit:  Int = 10,
    after:  String     # cursor-based pagination
  ): PostConnection!
  createdAt: DateTime!
  updatedAt: DateTime!
}

enum UserRole { USER ADMIN }

type Post implements Node & Timestamped {
  id:          ID!
  title:       String!
  body:        String!
  author:      User!
  tags:        [String!]!
  status:      PostStatus!
  comments:    [Comment!]!
  likeCount:   Int!
  viewCount:   Int!
  createdAt:   DateTime!
  updatedAt:   DateTime!
}

enum PostStatus { DRAFT PUBLISHED ARCHIVED }

# Cursor-based pagination connection pattern
type PostConnection {
  edges:    [PostEdge!]!
  pageInfo: PageInfo!
  total:    Int!
}
type PostEdge { node: Post!, cursor: String! }
type PageInfo { hasNextPage: Boolean!, endCursor: String }

# Input types for mutations
input CreatePostInput {
  title:  String!
  body:   String!
  tags:   [String!]
  status: PostStatus = DRAFT
}

# Custom directives
directive @auth(requires: UserRole = USER) on FIELD_DEFINITION
directive @rateLimit(max: Int!, window: Int!) on FIELD_DEFINITION

type Query {
  user(id: ID!):      User
  me:                 User          @auth
  posts(
    status: PostStatus,
    limit: Int = 10,
    after: String
  ): PostConnection!
}

type Mutation {
  createPost(input: CreatePostInput!): Post!   @auth
  updatePost(id: ID!, input: CreatePostInput!): Post! @auth
  deletePost(id: ID!): Boolean!               @auth(requires: ADMIN)
}

type Subscription {
  postCreated: Post!
  postUpdated(id: ID!): Post!
}
Schema Design Principles
  • Prefer cursor-based pagination over offset — it's stable when items are inserted or deleted.
  • Return mutation payloads, not scalars — this lets you evolve mutations without breaking changes.
  • Use ! (non-null) aggressively — it signals required data and enables better client-side type inference.
  • Never expose internal IDs or database details directly in the schema.
3

Apollo Server Setup

Apollo Server 4 is framework-agnostic — it runs as Express middleware, standalone, or on serverless platforms. Use datasource classes to encapsulate database/REST interactions.

typescriptserver.ts
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
import express from 'express';
import http from 'http';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';
import { createContext } from './context';

async function main() {
  const app    = express();
  const httpServer = http.createServer(app);

  const server = new ApolloServer({
    typeDefs,
    resolvers,
    plugins: [ApolloServerPluginDrainHttpServer({ httpServer })],
    formatError: (formattedError, error) => {
      // Don't expose internal errors in production
      if (process.env.NODE_ENV === 'production' && !formattedError.extensions?.code) {
        return { message: 'Internal server error' };
      }
      return formattedError;
    },
  });

  await server.start();

  app.use('/graphql',
    express.json(),
    expressMiddleware(server, {
      context: async ({ req }) => createContext(req),
    })
  );

  await new Promise(resolve => httpServer.listen(4000, resolve));
  console.log('🚀 GraphQL server ready at http://localhost:4000/graphql');
}
main();
typescriptcontext.ts
import { Request } from 'express';
import { User } from './models';
import { PostDataSource } from './datasources/PostDataSource';
import { UserDataSource } from './datasources/UserDataSource';
import { verifyToken } from './utils/auth';

export interface Context {
  user:        User | null;
  dataSources: {
    posts: PostDataSource;
    users: UserDataSource;
  };
}

export async function createContext(req: Request): Promise {
  const token = req.headers.authorization?.replace('Bearer ', '');
  let user: User | null = null;

  if (token) {
    try {
      const payload = verifyToken(token);
      user = await User.findById(payload.sub);
    } catch { /* invalid token — unauthenticated */ }
  }

  return {
    user,
    dataSources: {
      posts: new PostDataSource(),
      users: new UserDataSource(),
    },
  };
}
4

Authentication and Authorization

Implement authentication in the context function (run once per request). Implement authorisation in resolvers or via directives. Use graphql-shield for declarative, rule-based field-level permissions.

typescriptpermissions.ts (graphql-shield)
import { shield, rule, and, or, allow, deny } from 'graphql-shield';

const isAuthenticated = rule({ cache: 'contextual' })(
  async (_parent, _args, ctx: Context) => {
    return ctx.user !== null || new Error('You must be logged in');
  }
);

const isAdmin = rule({ cache: 'contextual' })(
  async (_parent, _args, ctx: Context) => {
    return ctx.user?.role === 'ADMIN' || new Error('Admin access required');
  }
);

const isPostOwner = rule({ cache: 'strict' })(
  async (_parent, args, ctx: Context) => {
    const post = await ctx.dataSources.posts.findById(args.id);
    return post?.authorId === ctx.user?.id || new Error('Not the post owner');
  }
);

export const permissions = shield({
  Query: {
    me:    isAuthenticated,
    posts: allow,
    user:  allow,
  },
  Mutation: {
    createPost: isAuthenticated,
    updatePost: and(isAuthenticated, isPostOwner),
    deletePost: and(isAuthenticated, or(isPostOwner, isAdmin)),
  },
  Subscription: {
    postCreated: isAuthenticated,
  },
}, {
  allowExternalErrors: true,
  fallbackError: 'Not authorised',
});
5

Efficient Data Fetching with DataLoader

Without batching, resolving a list of 10 posts that each request their author would fire 10 individual User.findById() queries — the classic N+1 problem. DataLoader solves this by batching all user lookups within a single tick into one query.

typescriptdatasources/UserDataSource.ts
import DataLoader from 'dataloader';
import { User } from '../models';

export class UserDataSource {
  // Batch: collect all requested IDs in one tick, then query once
  private userLoader = new DataLoader(
    async (ids) => {
      const users = await User.find({ _id: { $in: ids } });
      // CRITICAL: results must be in the same order as input IDs
      const map = new Map(users.map(u => [u.id, u]));
      return ids.map(id => map.get(id) ?? null);
    },
    { cache: true }   // per-request cache — same user fetched multiple times hits cache
  );

  async findById(id: string) {
    return this.userLoader.load(id);
  }

  async findByIds(ids: string[]) {
    return this.userLoader.loadMany(ids);
  }
}

// Resolver — looks like N+1 but DataLoader batches it
const resolvers = {
  Post: {
    author: (post: Post, _: unknown, ctx: Context) =>
      ctx.dataSources.users.findById(post.authorId),   // batched!
  },
};
Create DataLoader Per Request

Instantiate DataLoader inside the context function (once per request), not at the module level. A shared DataLoader would cache user data across requests, causing privacy leaks where user A sees user B's stale cached data.

6

Real-time Subscriptions

GraphQL subscriptions use WebSockets to push updates to clients. Apollo Server supports subscriptions via the graphql-ws library over a separate WebSocket server.

typescriptsubscriptions setup
import { useServer } from 'graphql-ws/lib/use/ws';
import { WebSocketServer } from 'ws';
import { makeExecutableSchema } from '@graphql-tools/schema';
import { PubSub } from 'graphql-subscriptions';

export const pubsub = new PubSub();

const schema = makeExecutableSchema({ typeDefs, resolvers });

// WebSocket server (separate from HTTP)
const wsServer = new WebSocketServer({ server: httpServer, path: '/graphql' });

useServer(
  {
    schema,
    context: async (ctx) => {
      // Authenticate WebSocket connection via connectionParams
      const token = ctx.connectionParams?.authorization as string;
      const user  = token ? await verifyAndGetUser(token) : null;
      return { user, dataSources: createDataSources() };
    },
    onConnect: async (ctx) => {
      if (!ctx.connectionParams?.authorization) {
        throw new Error('Unauthorised');
      }
    },
  },
  wsServer
);
typescriptSubscription resolvers
const POST_CREATED = 'POST_CREATED';

const resolvers = {
  Mutation: {
    createPost: async (_: unknown, { input }: CreatePostArgs, ctx: Context) => {
      const post = await ctx.dataSources.posts.create({
        ...input,
        authorId: ctx.user!.id,
      });

      // Publish event to all subscribers
      pubsub.publish(POST_CREATED, { postCreated: post });

      return post;
    },
  },

  Subscription: {
    postCreated: {
      subscribe: (_: unknown, _args: unknown, ctx: Context) => {
        if (!ctx.user) throw new Error('Not authenticated');
        return pubsub.asyncIterableIterator(POST_CREATED);
      },
      resolve: (payload: { postCreated: Post }) => payload.postCreated,
    },
  },
};
Production PubSub

The in-memory PubSub only works on a single server instance. For production with multiple instances, use Redis PubSub (graphql-redis-subscriptions) so events published on any instance reach all subscribers regardless of which instance they're connected to.

7

Performance Optimisation

GraphQL's flexibility is a double-edged sword — a malicious client can construct deeply nested or highly complex queries that overwhelm your server. Protect against this with query complexity analysis and depth limiting.

typescriptQuery complexity and depth limiting
import depthLimit         from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    depthLimit(7),                         // max 7 levels of nesting
    createComplexityLimitRule(1000, {      // max complexity score of 1000
      onCost: (cost) => console.log('Query cost:', cost),
      formatErrorMessage: (cost) =>
        `Query complexity ${cost} exceeds maximum of 1000`,
    }),
  ],
});
typescriptPersisted queries (APQ)
// Automatic Persisted Queries (APQ) — send only the hash on repeat requests
// Client (Apollo Client):
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';

const link = createPersistedQueryLink({ sha256 });

// Server — enable APQ with a Redis cache for persistence across restarts
import { KeyvAdapter } from '@apollo/utils.keyvadapter';
import KeyvRedis from '@keyv/redis';

const server = new ApolloServer({
  cache: new KeyvAdapter(new KeyvRedis(process.env.REDIS_URL)),
  // APQ is enabled automatically when a cache is provided
});
8

Testing GraphQL APIs

Test resolvers in isolation (unit tests) and the full GraphQL execution (integration tests). Use Apollo's executeOperation for integration tests without spinning up an HTTP server.

typescriptResolver unit test
import { resolvers } from '../resolvers';

describe('Post resolvers', () => {
  const mockCtx = {
    user: { id: 'user-1', role: 'USER' },
    dataSources: {
      posts: {
        findById: jest.fn(),
        create:   jest.fn(),
      },
      users: { findById: jest.fn() },
    },
  };

  describe('Query.posts', () => {
    it('returns published posts', async () => {
      const mockPosts = [{ id: '1', title: 'Hello', status: 'PUBLISHED' }];
      mockCtx.dataSources.posts.findById.mockResolvedValue(mockPosts);

      const result = await resolvers.Query.posts(
        {}, { status: 'PUBLISHED' }, mockCtx, {} as any
      );
      expect(result).toEqual(mockPosts);
    });
  });

  describe('Mutation.createPost', () => {
    it('creates post with correct author', async () => {
      const input = { title: 'New Post', body: 'Content', status: 'DRAFT' };
      const expected = { id: '2', ...input, authorId: 'user-1' };
      mockCtx.dataSources.posts.create.mockResolvedValue(expected);

      const result = await resolvers.Mutation.createPost(
        {}, { input }, mockCtx, {} as any
      );
      expect(mockCtx.dataSources.posts.create)
        .toHaveBeenCalledWith({ ...input, authorId: 'user-1' });
      expect(result).toEqual(expected);
    });
  });
});
typescriptIntegration test with executeOperation
import { ApolloServer } from '@apollo/server';
import { typeDefs } from '../schema';
import { resolvers } from '../resolvers';

const GET_ME = `
  query {
    me {
      id
      name
      email
    }
  }
`;

describe('me query', () => {
  let server: ApolloServer;
  beforeAll(async () => {
    server = new ApolloServer({ typeDefs, resolvers });
    await server.start();
  });
  afterAll(() => server.stop());

  it('returns null for unauthenticated request', async () => {
    const { body } = await server.executeOperation(
      { query: GET_ME },
      { contextValue: { user: null, dataSources: mockDataSources() } }
    );
    expect(body.kind).toBe('single');
    expect((body as any).singleResult.data?.me).toBeNull();
  });

  it('returns current user for authenticated request', async () => {
    const mockUser = { id: 'u1', name: 'Alice', email: 'alice@test.com' };
    const { body } = await server.executeOperation(
      { query: GET_ME },
      { contextValue: { user: mockUser, dataSources: mockDataSources() } }
    );
    expect((body as any).singleResult.data?.me).toEqual(mockUser);
  });
});