Skip to main content

⚙️ Queries, Mutations, and Resolvers

A schema declares what's possible; resolvers make it real. In this lesson you'll write expressive queries and mutations from the client side, then implement the resolver functions that fetch and change data on the server — including the batching, authentication, and error handling every production GraphQL API needs.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Write queries using variables, aliases, fragments, and the @include/@skip directives
  • Author mutations with input types and dedicated payload types
  • Implement resolvers and explain the (parent, args, context, info) signature
  • Solve the N+1 problem with DataLoader batching
  • Add authentication, authorization, and error handling to resolvers

Estimated Time: 45–55 minutes  •  Difficulty: Advanced

Hands-on: Implement resolvers for a small blog API with auth and validation.

In This Lesson

The Three Operations

GraphQL defines exactly three operation types:

  1. Queries — read data.
  2. Mutations — create, update, or delete data.
  3. Subscriptions — receive real-time updates (usually over WebSockets).

This lesson focuses on queries and mutations, and on the resolvers that fulfil both.

💡 A restaurant analogy. The schema is the menu. A query is ordering dishes that already exist. A mutation is asking the kitchen to make or change something. And the resolvers are the kitchen staff who actually know how to prepare each item on the menu.

Queries in Depth

A query names the fields you want, mirroring the shape of the JSON you'll get back:

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

Its parts: the operation type (query), a field (user), its arguments (id: "123"), and a nested selection for related data.

Query variables

Hard-coding values into a query is a mistake. Declare variables so the same query is reusable, type-checked, and safe from injection-style bugs:

query GetUser($userId: ID!) {
  user(id: $userId) {
    id
    name
    email
  }
}

Variables travel alongside the query as a separate JSON object:

{ "userId": "123" }

💡 Always parameterize

Variables aren't just convenient — they let the server validate the type of every input before executing, and they keep user-supplied values out of the query string. Treat inline literals as a demo-only shortcut.

Aliases, Fragments & Directives

Aliases

You can't request the same field twice with different arguments unless you rename the results. Aliases do exactly that:

query {
  activeUser: user(id: "123") { id fullName: name email }
  adminUser:  user(id: "456") { id fullName: name email }
}

Response

{
  "data": {
    "activeUser": { "id": "123", "fullName": "Alice Smith", "email": "alice@example.com" },
    "adminUser":  { "id": "456", "fullName": "Bob Jones",  "email": "bob@example.com" }
  }
}

Fragments

A fragment is a reusable selection set — the DRY principle for queries:

fragment UserBasics on User {
  id
  name
  email
}

query {
  regularUser: user(id: "123") { ...UserBasics }
  contentCreator: user(id: "456") {
    ...UserBasics
    posts { id title }
  }
}

Directives: @include and @skip

Two built-in query directives conditionally include fields based on a variable:

query GetUserDetails($withPosts: Boolean!, $skipEmail: Boolean!) {
  user(id: "123") {
    id
    name
    email @skip(if: $skipEmail)
    posts @include(if: $withPosts) {
      title
    }
  }
}
  • @include(if: Boolean) — include the field only when the argument is true.
  • @skip(if: Boolean) — omit the field when the argument is true.

Mutations & Payloads

Mutations change server-side data. They look like queries but use the mutation keyword and typically return the object they affected:

mutation CreateUser($input: CreateUserInput!) {
  createUser(input: $input) {
    id
    name
    email
  }
}

Two behaviors set mutations apart from queries: they always use the explicit mutation keyword, and top-level mutation fields execute sequentially (queries may resolve in parallel), so ordering side effects is predictable.

Payload types

Returning the bare object works, but production APIs usually return a dedicated payload type. It carries the affected object and structured errors, and it can grow new fields later without breaking clients:

type CreateUserPayload {
  user: User
  errors: [UserError!]!
}

type UserError {
  path: String!
  message: String!
  code: String!
}

type Mutation {
  createUser(input: CreateUserInput!): CreateUserPayload!
}

📖 Why payloads beat throwing for validation

A thrown error lands in the top-level errors array and makes data null — fine for truly exceptional failures. But expected problems like "email already taken" are part of normal flow. Returning them as typed errors in the payload lets the client render field-level messages without treating the whole request as a crash.

Resolvers: The Implementation

A resolver is a function that produces the value for a single field. Resolvers are the bridge between your schema and your data sources.

graph LR A[Client] -->|query| B[GraphQL server] B -->|parse & validate| C[Execution] C -->|call resolvers| D[Resolvers] D -->|read/write| E[Data sources] E -->|values| D D -->|field values| C C -->|JSON response| A

The resolver signature

Every resolver receives four arguments:

const resolvers = {
  User: {
    // parent:  the User object this field belongs to
    // args:    arguments for THIS field (e.g. limit, offset)
    // context: shared per-request object (auth, db, loaders)
    // info:    execution details (field name, path, AST)
    posts: (parent, args, context, info) => {
      return context.db.post.findMany({ where: { authorId: parent.id } });
    },
  },
};
  • parent — the result of the resolver one level up (the object holding this field).
  • args — the arguments supplied to this field in the query.
  • context — a value shared by every resolver in a request; the home for the database client, the authenticated user, and DataLoaders.
  • info — metadata about the execution (rarely needed day to day).

Nested, field-level resolution

GraphQL resolves the query field by field, walking down the tree. Given this schema and query:

query {
  user(id: "123") {
    name
    posts {
      title
      comments { text }
    }
  }
}

the server runs, in order: the user resolver → reads name off the returned object → the posts resolver → for each post reads title and runs the comments resolver. Any field without an explicit resolver uses the default resolver, which simply reads the matching property off parent. That's why scalar fields like name usually need no resolver at all.

const resolvers = {
  Query: {
    user: (_parent, { id }, { db }) => db.user.findUnique({ where: { id } }),
  },
  User: {
    posts: (user, _args, { db }) => db.post.findMany({ where: { authorId: user.id } }),
  },
  Post: {
    comments: (post, _args, { db }) => db.comment.findMany({ where: { postId: post.id } }),
  },
};

The N+1 Problem & DataLoader

Field-level resolution is elegant, but it can quietly fire a flood of database queries. Consider:

query {
  posts(limit: 10) {
    title
    author { name }
  }
}

The posts resolver runs once (1 query), then the author resolver runs once per post (10 queries) — 11 queries for 10 posts. Scale that to a page of 100 items and the database groans. This is the same N+1 pattern we saw with REST, now on the server side.

⚠️ It hides in plain sight

Because each resolver looks innocent on its own, N+1 rarely shows up in code review — it shows up as slow queries in production. Assume any resolver that loads a related record inside a list needs batching.

DataLoader fixes this by batching all the individual loads that happen in a single tick into one query, and caching results per request:

import DataLoader from 'dataloader';

// Create fresh loaders for EACH request (never share across requests)
export function createLoaders(db) {
  return {
    userLoader: new DataLoader(async (userIds) => {
      const users = await db.user.findMany({ where: { id: { in: userIds } } });
      // Return in the SAME order as the requested ids
      const byId = new Map(users.map((u) => [u.id, u]));
      return userIds.map((id) => byId.get(id) ?? null);
    }),
  };
}

// In the resolver, load by key — DataLoader batches the calls
const resolvers = {
  Post: {
    author: (post, _args, { loaders }) => loaders.userLoader.load(post.authorId),
  },
};

Now those 10 author lookups collapse into a single WHERE id IN (...) query. Two rules matter: the batch function must return results in the exact order of the keys, and loaders must be created per request so their cache never leaks between users.

Auth & Error Handling

GraphQL has no built-in auth. The standard pattern is to authenticate once when building the context, then read context.user in resolvers.

import jwt from 'jsonwebtoken';

// Build context once per request
export async function buildContext({ req, db }) {
  const token = (req.headers.authorization ?? '').replace('Bearer ', '');
  let user = null;
  if (token) {
    try {
      const { userId } = jwt.verify(token, process.env.JWT_SECRET);
      user = await db.user.findUnique({ where: { id: userId } });
    } catch {
      // Invalid/expired token → treat as anonymous
    }
  }
  return { db, user, loaders: createLoaders(db) };
}

Throwing structured errors

For genuinely exceptional cases, throw a GraphQLError with an extensions.code so clients can branch on the machine-readable code rather than the message text:

import { GraphQLError } from 'graphql';

const resolvers = {
  Query: {
    me: (_p, _a, { user }) => {
      if (!user) {
        throw new GraphQLError('You must be logged in', {
          extensions: { code: 'UNAUTHENTICATED' },
        });
      }
      return user;
    },
    adminDashboard: (_p, _a, { user }) => {
      if (!user) throw new GraphQLError('Not authenticated', { extensions: { code: 'UNAUTHENTICATED' } });
      if (user.role !== 'ADMIN') throw new GraphQLError('Forbidden', { extensions: { code: 'FORBIDDEN' } });
      return getDashboardData();
    },
  },
};

📖 Modern packages

Older tutorials import AuthenticationError and ForbiddenError from the deprecated apollo-server package. In Apollo Server v4+ those helpers are gone — throw a plain GraphQLError from the core graphql package with an extensions.code, exactly as above.

Two shapes of error

  • Throw for exceptional failures (not authenticated, record truly missing, database down). These populate the top-level errors array.
  • Return typed errors in a payload for expected validation problems ("email already in use", "password too short"), so the client can show field-level feedback.

Putting It Together

Here's a compact but complete Apollo Server v4 setup that combines resolvers, context, DataLoader, auth, and payload-based validation. (Assume typeDefs holds the SDL from the previous lesson and db is a Prisma-style client.)

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { GraphQLError } from 'graphql';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';

const resolvers = {
  Query: {
    post: (_p, { id }, { db }) => db.post.findUnique({ where: { id } }),
    posts: (_p, { filter = {} }, { db }) =>
      db.post.findMany({ where: buildPostWhere(filter) }),
    me: (_p, _a, { user }) => {
      if (!user) throw new GraphQLError('Not authenticated', { extensions: { code: 'UNAUTHENTICATED' } });
      return user;
    },
  },

  Mutation: {
    register: async (_p, { input }, { db }) => {
      const errors = [];
      if (!input.email.includes('@')) errors.push({ path: 'email', message: 'Invalid email' });
      if (input.password.length < 8) errors.push({ path: 'password', message: 'Password too short' });

      const existing = await db.user.findUnique({ where: { email: input.email } });
      if (existing) errors.push({ path: 'email', message: 'Email already in use' });
      if (errors.length) return { user: null, token: null, errors };

      const hashed = await bcrypt.hash(input.password, 10);
      const user = await db.user.create({ data: { ...input, password: hashed } });
      const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, { expiresIn: '1d' });
      return { user, token, errors: [] };
    },

    createPost: async (_p, { input }, { db, user }) => {
      if (!user) return { post: null, errors: [{ path: 'auth', message: 'You must be logged in' }] };
      if (!input.title.trim()) return { post: null, errors: [{ path: 'title', message: 'Title required' }] };
      const post = await db.post.create({ data: { ...input, authorId: user.id } });
      return { post, errors: [] };
    },
  },

  // Type resolvers — batched to avoid N+1
  User: {
    posts: (user, _a, { loaders }) => loaders.postsByUserLoader.load(user.id),
  },
  Post: {
    author: (post, _a, { loaders }) => loaders.userLoader.load(post.authorId),
  },
};

const server = new ApolloServer({ typeDefs, resolvers });

const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
  context: async ({ req }) => buildContext({ req, db }),
});

console.log(`🚀 Server ready at ${url}`);

✅ This one file demonstrates

  • Query and mutation resolvers with realistic logic
  • JWT authentication read from context
  • DataLoader batching on the type-level resolvers
  • Payload-based validation errors, not thrown exceptions
  • The current @apollo/server + startStandaloneServer API

Hands-on Exercise

🏋️ Implement resolvers for a blog API

Objective: Wire up queries, a mutation, and a nested resolver, with auth and validation, against this schema fragment:

type Query {
  post(id: ID!): Post
  me: User
}

type Mutation {
  createPost(input: CreatePostInput!): PostPayload!
}

type Post { id: ID! title: String! author: User! }
type User { id: ID! name: String! posts: [Post!]! }
type PostPayload { post: Post errors: [Error!]! }
type Error { path: String! message: String! }

input CreatePostInput { title: String! content: String! }

Tasks:

  1. Implement Query.post and Query.me (the latter requires auth).
  2. Implement Mutation.createPost with a title-not-empty check that returns payload errors.
  3. Implement the nested Post.author and User.posts resolvers.
  4. Bonus: batch Post.author with a DataLoader.
💡 Hint

Read context.user for auth; return { post: null, errors: [...] } for validation problems instead of throwing; and remember Post.author's parent is the post (use parent.authorId).

✅ Sample solution
import { GraphQLError } from 'graphql';

const resolvers = {
  Query: {
    post: (_p, { id }, { db }) => db.post.findUnique({ where: { id } }),
    me: (_p, _a, { user }) => {
      if (!user) throw new GraphQLError('Not authenticated', { extensions: { code: 'UNAUTHENTICATED' } });
      return user;
    },
  },
  Mutation: {
    createPost: async (_p, { input }, { db, user }) => {
      if (!user) return { post: null, errors: [{ path: 'auth', message: 'Log in first' }] };
      if (!input.title.trim()) return { post: null, errors: [{ path: 'title', message: 'Title required' }] };
      const post = await db.post.create({ data: { ...input, authorId: user.id } });
      return { post, errors: [] };
    },
  },
  User: {
    posts: (user, _a, { db }) => db.post.findMany({ where: { authorId: user.id } }),
  },
  Post: {
    author: (post, _a, { loaders }) => loaders.userLoader.load(post.authorId),
  },
};

Quiz

🎯 Check Your Understanding

Question 1: In a resolver (parent, args, context, info), where does the authenticated user and database client belong?

Question 2: A query for 10 posts fires 1 query for the list plus 1 per post for its author. What technique batches those author loads into one query?

Question 3: For an expected validation problem like "email already in use", which approach is preferred?

Summary

🎉 Key Takeaways

  • Queries read data; variables, aliases, fragments, and @include/@skip make them reusable and flexible.
  • Mutations change data, run sequentially, and are best returned through payload types carrying both the object and typed errors.
  • Resolvers take (parent, args, context, info); scalars fall back to the default resolver.
  • The N+1 problem is real on the server — batch related loads with DataLoader, created per request.
  • Authenticate once in context; throw GraphQLError for exceptional cases and return payload errors for validation.

📚 Further Reading

🚀 What's Next?

You've now built a functional GraphQL API end to end. Next we zoom out from single services to system design — Microservices Design Principles — where GraphQL often acts as the gateway that stitches many services together.

🎉 Excellent work!

Schema, queries, mutations, resolvers, batching, and auth — you can build a real GraphQL server now.