Queries, mutations and resolvers

How a document becomes a result: the resolver map, the per-request context, and the execution stages.

Resolvers

const resolvers = {
  Query: {
    post: (_parent, { id }, { db }) => db.post.findById(id),
    posts: (_parent, { first = 10, after }, { db }) =>
      db.post.list({ limit: first, cursor: after }),
  },
  Post: {
    // parent is the post returned by Query.post
    author: (post, _args, { loaders }) => loaders.user.load(post.authorId),
    comments: (post, { first = 5 }, { db }) =>
      db.comment.listByPost(post.id, { limit: first }),
  },
  Mutation: {
    createPost: async (_parent, { input }, { db, user }) => {
      if (!user) {
        return { post: null, errors: [{ field: null, message: "Not signed in" }] };
      }
      const post = await db.post.create({ ...input, authorId: user.id });
      return { post, errors: [] };
    },
  },
};
  • Every field may have a resolver. Omit one and the default resolver reads the property with the same name from the parent object.
  • A resolver receives the parent value, the arguments, and the context — nothing else. That signature is the whole interface.
  • The context object carries per-request state: database handles, the authenticated user, and per-request data loaders.
  • A resolver may return a value, a promise, or a list of promises; the executor awaits them all and assembles the response tree.
  • Keep resolvers thin. Authorisation checks and business rules belong in services the resolver calls, so REST and background jobs can reuse them.

The execution lifecycle

import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";

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

const { url } = await startStandaloneServer(server, {
  context: async ({ req }) => ({
    user: await authenticate(req),
    db,
    loaders: makeLoaders(db),     // fresh loaders per request
  }),
  listen: { port: 4000 },
});

console.log("GraphQL ready at " + url);
StageWhat happens
ParseThe document becomes an AST; a syntax error stops here
ValidateEvery field, argument and variable is checked against the schema
ExecuteResolvers run field by field, in parallel where fields are independent
SerialiseThe response is built as a data tree plus an errors array
IntrospectClients can query __schema to discover every type
⚠️
GraphQL usually answers with HTTP 200 and an errors array even when something failed, and a partial result plus errors is a valid response. Client code must check errors, not only the status code, or it will silently render missing data.

FAQ

Where does authorisation belong?
In the resolver, or in the service it calls, applied per field or per object. There is one shared endpoint, so there is no per-route filter to attach it to.
How should a mutation report expected failures?
Model them as payload fields such as an errors list, and reserve the top-level errors array for unexpected problems. Clients can then handle both in a typed way.

Schema and type system When GraphQL beats REST, and its pitfalls

Last refreshed 2026-09-18.