Skip to content
C Codeloom
GraphQL

GraphQL Resolver Patterns Explained

Compare resolver patterns in GraphQL: thin resolvers, service layers, DataLoader batching, and error handling that scales.

·4 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • Thin resolver vs fat resolver patterns
  • Service layers and where logic should live
  • Batching with DataLoader
  • Per-request context for auth and tracing
  • Resolver-level error handling

Prerequisites

  • Comfortable with GraphQL schemas and resolvers

What and Why

A resolver runs every time a field is requested. The way you organize them shapes how your app handles authorization, caching, and performance. Good patterns let you grow the schema without growing complexity.

Mental Model

Treat resolvers as adapters. They translate field arguments into service calls, then shape the result. The interesting work lives in services. Context carries per-request information like the user, a DB connection, and DataLoader instances.

Resolver (thin)
 |
 v
Service (business rules)
 |
 v
Repository / DataLoader (data access)
 |
 v
Database / external API
Where logic lives

Hands-on Example

A clean resolver setup with services and DataLoader.

// services/userService.js
export const userService = {
  async getById(id, ctx) {
    return ctx.loaders.user.load(id);
  },
  async list(ctx) {
    return ctx.db.user.findMany();
  },
};

// loaders.js
import DataLoader from "dataloader";

export function buildLoaders(db) {
  return {
    user: new DataLoader(async (ids) => {
      const rows = await db.user.findMany({ where: { id: { in: ids } } });
      const map = new Map(rows.map((r) => [r.id, r]));
      return ids.map((id) => map.get(id) || null);
    }),
  };
}

// context.js
export async function buildContext({ req, db }) {
  const user = await authenticate(req);
  return { user, db, loaders: buildLoaders(db) };
}

// resolvers.js
import { userService } from "./services/userService.js";

export const resolvers = {
  Query: {
    me: (_, __, ctx) => ctx.user,
    user: (_, { id }, ctx) => userService.getById(id, ctx),
    users: (_, __, ctx) => userService.list(ctx),
  },
  Post: {
    author: (post, _, ctx) => ctx.loaders.user.load(post.authorId),
  },
};

Authorization belongs near the action, not scattered everywhere.

function requireUser(ctx) {
  if (!ctx.user) throw new GraphQLError("UNAUTHENTICATED", { extensions: { code: "UNAUTHENTICATED" } });
  return ctx.user;
}

export const resolvers = {
  Mutation: {
    deletePost: async (_, { id }, ctx) => {
      const user = requireUser(ctx);
      const post = await ctx.db.post.findUnique({ where: { id } });
      if (!post || post.authorId !== user.id) {
        throw new GraphQLError("FORBIDDEN", { extensions: { code: "FORBIDDEN" } });
      }
      await ctx.db.post.delete({ where: { id } });
      return true;
    },
  },
};

Errors should carry a code and stay typed.

import { GraphQLError } from "graphql";

throw new GraphQLError("Post not found", {
  extensions: { code: "NOT_FOUND", postId: id },
});

Common Pitfalls

  • Fat resolvers that talk to the database, format output, and check auth all in one function. Tests get painful and reuse becomes hard.
  • Skipping DataLoader and ending up with the N+1 problem on relationship fields.
  • Throwing generic Error objects. Clients cannot tell apart “not found” from “internal failure” without codes.
  • Mutating context. Treat it as read-only after creation. Build new objects when you need to layer state.
  • Putting business rules in the schema layer. The schema should describe shape, not enforce policy.

Practical Tips

  • Keep resolvers under 20 lines. If they grow, extract a service function.
  • Co-locate resolvers per type, not per file dump. User.resolvers.js, Post.resolvers.js, then a stitch step.
  • Use TypeScript code generation to type both resolvers and operations.
  • Centralize permission checks in helpers like requireUser, requireOwner, requireRole.
  • Add a loaders namespace to context so adding a new batched loader is one line.

Wrap-up

Patterns matter more than syntax. Keep resolvers thin, push logic into services, batch with DataLoader, and codify errors and auth into helpers. Your schema then grows naturally and your team can reason about each layer in isolation, which is exactly the kind of structure that turns GraphQL from a fun toy into a maintainable production system.