Skip to main content
Technology & EngineeringApi Frameworks424 lines

Apollo GraphQL

"Apollo GraphQL: schema design, resolvers, Apollo Server, Apollo Client with React, useQuery/useMutation, caching strategies, subscriptions, and codegen"

Quick Summary18 lines
GraphQL provides a typed query language where clients request exactly the data they need — no over-fetching, no under-fetching. Apollo is the most widely adopted GraphQL ecosystem, providing Apollo Server for the backend and Apollo Client for the frontend with a sophisticated normalized cache.

## Key Points

- Use DataLoader for every relationship resolver to batch database queries and eliminate N+1 problems.
- Define `typePolicies` in the InMemoryCache to control how paginated and merged data behaves.
- Throw `GraphQLError` with extension codes (`UNAUTHENTICATED`, `FORBIDDEN`, `BAD_USER_INPUT`) for structured error handling.
- Run codegen in CI to keep generated types in sync with the schema; never hand-write query types.
- Use `cache.modify` and `cache.evict` for precise cache updates after mutations rather than refetching entire queries.
- Keep resolvers thin — delegate business logic to service functions that are independently testable.
- **N+1 queries without DataLoader.** Field resolvers that query the database individually per parent record destroy performance at scale.
- **Fetching everything with `SELECT *` style queries.** GraphQL's value is selective field fetching; design resolvers to respect the requested field set.
- **Disabling the cache.** Apollo Client's normalized cache is its primary advantage; work with `typePolicies` instead of setting `fetchPolicy: "no-cache"` everywhere.
- **Deeply nested schemas without limits.** Unbounded nesting (user -> posts -> comments -> author -> posts) allows malicious queries; use depth limiting.
- **Putting auth logic in every resolver.** Use schema directives or middleware-style plugins for cross-cutting authentication and authorization.
- **Returning raw database models.** Map database entities to GraphQL types explicitly to avoid leaking internal fields or sensitive data.
skilldb get api-frameworks-skills/Apollo GraphQLFull skill: 424 lines
Paste into your CLAUDE.md or agent config

Apollo GraphQL

Core Philosophy

GraphQL provides a typed query language where clients request exactly the data they need — no over-fetching, no under-fetching. Apollo is the most widely adopted GraphQL ecosystem, providing Apollo Server for the backend and Apollo Client for the frontend with a sophisticated normalized cache.

The schema serves as the contract between client and server. Resolvers implement the data-fetching logic for each field. On the client, Apollo Client manages server state with automatic caching, refetching, and optimistic updates through React hooks. Combined with codegen, every query, mutation, and subscription is fully type-safe from schema to component.

Setup

Apollo Server

// Install: npm install @apollo/server graphql

// src/schema.ts — Type definitions
export const typeDefs = `#graphql
  type User {
    id: ID!
    name: String!
    email: String!
    posts: [Post!]!
    createdAt: String!
  }

  type Post {
    id: ID!
    title: String!
    content: String!
    published: Boolean!
    author: User!
    comments: [Comment!]!
    createdAt: String!
  }

  type Comment {
    id: ID!
    text: String!
    author: User!
    post: Post!
  }

  type Query {
    users: [User!]!
    user(id: ID!): User
    posts(published: Boolean, limit: Int, offset: Int): [Post!]!
    post(id: ID!): Post
  }

  input CreatePostInput {
    title: String!
    content: String!
    published: Boolean = false
  }

  input UpdatePostInput {
    title: String
    content: String
    published: Boolean
  }

  type Mutation {
    createPost(input: CreatePostInput!): Post!
    updatePost(id: ID!, input: UpdatePostInput!): Post!
    deletePost(id: ID!): Boolean!
    addComment(postId: ID!, text: String!): Comment!
  }

  type Subscription {
    commentAdded(postId: ID!): Comment!
    postPublished: Post!
  }
`;

// src/server.ts
import { ApolloServer } from "@apollo/server";
import { expressMiddleware } from "@apollo/server/express4";
import { createServer } from "http";
import express from "express";
import cors from "cors";
import { typeDefs } from "./schema";
import { resolvers } from "./resolvers";

interface Context {
  user: { id: string; role: string } | null;
  db: typeof prisma;
}

const app = express();
const httpServer = createServer(app);

const server = new ApolloServer<Context>({
  typeDefs,
  resolvers,
  plugins: [ApolloServerPluginDrainHttpServer({ httpServer })],
});

await server.start();

app.use(
  "/graphql",
  cors({ origin: "http://localhost:3000" }),
  express.json(),
  expressMiddleware(server, {
    context: async ({ req }): Promise<Context> => {
      const token = req.headers.authorization?.replace("Bearer ", "");
      const user = token ? await verifyToken(token) : null;
      return { user, db: prisma };
    },
  })
);

httpServer.listen(4000);

Key Techniques

Resolvers

// src/resolvers.ts
import { GraphQLError } from "graphql";

export const resolvers = {
  Query: {
    users: async (_: unknown, __: unknown, ctx: Context) => {
      return ctx.db.user.findMany();
    },

    user: async (_: unknown, args: { id: string }, ctx: Context) => {
      return ctx.db.user.findUnique({ where: { id: args.id } });
    },

    posts: async (
      _: unknown,
      args: { published?: boolean; limit?: number; offset?: number },
      ctx: Context
    ) => {
      return ctx.db.post.findMany({
        where: args.published !== undefined ? { published: args.published } : undefined,
        take: args.limit ?? 20,
        skip: args.offset ?? 0,
        orderBy: { createdAt: "desc" },
      });
    },
  },

  Mutation: {
    createPost: async (_: unknown, args: { input: CreatePostInput }, ctx: Context) => {
      if (!ctx.user) {
        throw new GraphQLError("Authentication required", {
          extensions: { code: "UNAUTHENTICATED" },
        });
      }

      return ctx.db.post.create({
        data: { ...args.input, authorId: ctx.user.id },
      });
    },

    deletePost: async (_: unknown, args: { id: string }, ctx: Context) => {
      if (!ctx.user) {
        throw new GraphQLError("Authentication required", {
          extensions: { code: "UNAUTHENTICATED" },
        });
      }

      const post = await ctx.db.post.findUnique({ where: { id: args.id } });
      if (post?.authorId !== ctx.user.id && ctx.user.role !== "admin") {
        throw new GraphQLError("Not authorized", {
          extensions: { code: "FORBIDDEN" },
        });
      }

      await ctx.db.post.delete({ where: { id: args.id } });
      return true;
    },
  },

  // Field-level resolvers for relationships
  User: {
    posts: async (parent: { id: string }, _: unknown, ctx: Context) => {
      return ctx.db.post.findMany({ where: { authorId: parent.id } });
    },
  },

  Post: {
    author: async (parent: { authorId: string }, _: unknown, ctx: Context) => {
      return ctx.db.user.findUnique({ where: { id: parent.authorId } });
    },
    comments: async (parent: { id: string }, _: unknown, ctx: Context) => {
      return ctx.db.comment.findMany({ where: { postId: parent.id } });
    },
  },
};

DataLoader for N+1 Prevention

// src/loaders.ts
import DataLoader from "dataloader";

export function createLoaders(db: typeof prisma) {
  return {
    userLoader: new DataLoader<string, User>(async (ids) => {
      const users = await db.user.findMany({ where: { id: { in: [...ids] } } });
      const userMap = new Map(users.map((u) => [u.id, u]));
      return ids.map((id) => userMap.get(id)!);
    }),

    postsByAuthorLoader: new DataLoader<string, Post[]>(async (authorIds) => {
      const posts = await db.post.findMany({
        where: { authorId: { in: [...authorIds] } },
      });
      const grouped = new Map<string, Post[]>();
      posts.forEach((p) => {
        const list = grouped.get(p.authorId) ?? [];
        list.push(p);
        grouped.set(p.authorId, list);
      });
      return authorIds.map((id) => grouped.get(id) ?? []);
    }),
  };
}

// Use in resolvers
Post: {
  author: (parent, _, ctx) => ctx.loaders.userLoader.load(parent.authorId),
},
User: {
  posts: (parent, _, ctx) => ctx.loaders.postsByAuthorLoader.load(parent.id),
},

Apollo Client Setup

// Install: npm install @apollo/client graphql

// src/lib/apollo.ts
import { ApolloClient, InMemoryCache, createHttpLink, split } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
import { GraphQLWsLink } from "@apollo/client/link/subscriptions";
import { getMainDefinition } from "@apollo/client/utilities";
import { createClient } from "graphql-ws";

const httpLink = createHttpLink({ uri: "/graphql" });

const authLink = setContext((_, { headers }) => {
  const token = localStorage.getItem("token");
  return {
    headers: { ...headers, authorization: token ? `Bearer ${token}` : "" },
  };
});

const wsLink = new GraphQLWsLink(
  createClient({ url: "ws://localhost:4000/graphql" })
);

const splitLink = split(
  ({ query }) => {
    const def = getMainDefinition(query);
    return def.kind === "OperationDefinition" && def.operation === "subscription";
  },
  wsLink,
  authLink.concat(httpLink)
);

export const client = new ApolloClient({
  link: splitLink,
  cache: new InMemoryCache({
    typePolicies: {
      Query: {
        fields: {
          posts: {
            keyArgs: ["published"],
            merge(existing = [], incoming) {
              return [...existing, ...incoming];
            },
          },
        },
      },
    },
  }),
});

React Hooks — useQuery and useMutation

// src/components/PostList.tsx
import { gql, useQuery, useMutation } from "@apollo/client";

const GET_POSTS = gql`
  query GetPosts($published: Boolean, $limit: Int, $offset: Int) {
    posts(published: $published, limit: $limit, offset: $offset) {
      id
      title
      content
      published
      author {
        id
        name
      }
      createdAt
    }
  }
`;

const DELETE_POST = gql`
  mutation DeletePost($id: ID!) {
    deletePost(id: $id)
  }
`;

export function PostList() {
  const { data, loading, error, fetchMore } = useQuery(GET_POSTS, {
    variables: { published: true, limit: 10, offset: 0 },
  });

  const [deletePost] = useMutation(DELETE_POST, {
    update(cache, _, { variables }) {
      cache.modify({
        fields: {
          posts(existing = [], { readField }) {
            return existing.filter(
              (ref: any) => readField("id", ref) !== variables?.id
            );
          },
        },
      });
    },
    optimisticResponse: { deletePost: true },
  });

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <div>
      {data.posts.map((post: any) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>By {post.author.name}</p>
          <button onClick={() => deletePost({ variables: { id: post.id } })}>
            Delete
          </button>
        </article>
      ))}
      <button onClick={() =>
        fetchMore({ variables: { offset: data.posts.length } })
      }>
        Load more
      </button>
    </div>
  );
}

Code Generation for Type Safety

// Install: npm install -D @graphql-codegen/cli @graphql-codegen/typescript
//   @graphql-codegen/typescript-operations @graphql-codegen/typescript-react-apollo

// codegen.ts
import type { CodegenConfig } from "@graphql-codegen/cli";

const config: CodegenConfig = {
  schema: "http://localhost:4000/graphql",
  documents: ["src/**/*.tsx", "src/**/*.ts"],
  generates: {
    "src/generated/graphql.ts": {
      plugins: [
        "typescript",
        "typescript-operations",
        "typescript-react-apollo",
      ],
      config: {
        withHooks: true,
        withHOC: false,
        withComponent: false,
      },
    },
  },
};

export default config;

// After running: npx graphql-codegen
// Usage with generated hooks:
import { useGetPostsQuery, useDeletePostMutation } from "@/generated/graphql";

function Posts() {
  const { data, loading } = useGetPostsQuery({ variables: { published: true } });
  const [deletePost] = useDeletePostMutation();
  // Fully typed data.posts, variables, etc.
}

Best Practices

  • Use DataLoader for every relationship resolver to batch database queries and eliminate N+1 problems.
  • Define typePolicies in the InMemoryCache to control how paginated and merged data behaves.
  • Throw GraphQLError with extension codes (UNAUTHENTICATED, FORBIDDEN, BAD_USER_INPUT) for structured error handling.
  • Run codegen in CI to keep generated types in sync with the schema; never hand-write query types.
  • Use cache.modify and cache.evict for precise cache updates after mutations rather than refetching entire queries.
  • Keep resolvers thin — delegate business logic to service functions that are independently testable.

Anti-Patterns

  • N+1 queries without DataLoader. Field resolvers that query the database individually per parent record destroy performance at scale.
  • Fetching everything with SELECT * style queries. GraphQL's value is selective field fetching; design resolvers to respect the requested field set.
  • Disabling the cache. Apollo Client's normalized cache is its primary advantage; work with typePolicies instead of setting fetchPolicy: "no-cache" everywhere.
  • Deeply nested schemas without limits. Unbounded nesting (user -> posts -> comments -> author -> posts) allows malicious queries; use depth limiting.
  • Putting auth logic in every resolver. Use schema directives or middleware-style plugins for cross-cutting authentication and authorization.
  • Returning raw database models. Map database entities to GraphQL types explicitly to avoid leaking internal fields or sensitive data.

Install this skill directly: skilldb add api-frameworks-skills

Get CLI access →