Skip to content
Santekno.com | Tech Tutorials and Trends
EN
📖 0%
27 Sep 2025 · 6 min read ·Article 89 / 125
Go

89 Writing Clean and Reusable Resolvers

IH
Ihsan Arif
Writer at Santekno · Backend Engineer

89 Writing Clean and Reusable Resolvers: An Engineer’s Guide

Quite a few of us backend engineers have worked with GraphQL at some point. One of the key elements in building a GraphQL Server is the resolver. But while GraphQL gives us freedom and flexibility, the resolvers we write often balloon quickly, become repetitive, hard to manage, and difficult to reuse. This article covers 89 ways to write clean and reusable resolvers—(yes, the number 89 is hyperbole, but the point stands: there are takeaways for everyone!) Complete with code examples, simulations, and even a mermaid diagram to make the concepts crystal clear.


What Is a Resolver?

Before we dive deeper, let’s quickly refresh what a resolver is. A resolver is a function/handler that performs the data-fetching process for each field in a GraphQL schema.

js
1const resolvers = {
2  Query: {
3    user: (_, { id }) => {
4      return users.find((user) => user.id === id);
5    },
6  },
7};

Simple, right? But once the schema grows, business requirements pile up, and error handling becomes increasingly complex, our resolver can turn into something like this (which might feel all too familiar):

js
 1const resolvers = {
 2  Query: {
 3    user: async (_, { id }, context) => {
 4      try {
 5        if (!context.user) {
 6          throw new Error('Unauthorized');
 7        }
 8        const user = await db.getUserById(id);
 9        if (!user) {
10          throw new Error('User not found');
11        }
12        // Logging, transform, etc.
13        context.logger.log('fetching user:', id);
14        return user;
15      } catch (err) {
16        context.logger.error(err);
17        throw err;
18      }
19    },
20  },
21};

Resolvers like this tend to bloat easily and are hard to reuse. So, how do we write resolvers that are clean and reusable?


Principles for Writing Clean Resolvers

Let’s break down some of the fundamental principles (not limited to 89, of course!):

PrincipleBrief Explanation
Single ResponsibilityEach resolver does ONE specific thing.
ModularExtract common functions into separate utils/helpers.
Don’t Repeat YourselfAvoid copy-pasting back and forth; use abstractions.
Use MiddlewareInject cross-cutting logic (auth, logging) via middleware.
TypedUse data types (TypeScript) for validation and autocomplete.
Dry Out Error HandlingUse a centralized error handler whenever possible.
Use Loader/CacheUse DataLoader/similar to batch and cache DB requests.
Unit TestableMake sure each resolver is easy to isolate for testing.

Code Example: Building Modular & Reusable Resolvers

Let’s take the ugly resolver above and break it down into several modular, reusable pieces.

1. Abstracting Context (Authorization as Middleware)

Instead of having every resolver check context.user one by one, use a higher-order resolver (middleware):

js
1// middleware/requireAuth.js
2function requireAuth(resolver) {
3  return (parent, args, context, info) => {
4    if (!context.user) {
5      throw new Error('Unauthorized');
6    }
7    return resolver(parent, args, context, info);
8  };
9}

2. Modularizing CRUD Functions

Move the DB and data-transformation functions out of the resolver:

js
 1// db/user.js
 2async function getUserById(id) {
 3  // e.g., access to Postgres/Mongo
 4  return await db.users.findOne({ id });
 5}
 6
 7// helpers/logger.js
 8function logRequest(logger, message) {
 9  logger.log(`[Request]: ${message}`);
10}

3. Use DataLoader

To avoid the N+1 problem:

js
 1// loaders/userLoader.js
 2const DataLoader = require('dataloader');
 3const { getUsersByIds } = require('../db/user');
 4
 5const userLoader = new DataLoader(async (ids) => {
 6  const users = await getUsersByIds(ids);
 7  return ids.map((id) => users.find((user) => user.id === id));
 8});
 9
10module.exports = userLoader;

4. Centralized Error Handling (Optional)

Rather than wrapping every resolver in a try-catch, you can do it via middleware too:

js
 1function withErrorHandler(resolver) {
 2  return async (parent, args, context, info) => {
 3    try {
 4      return await resolver(parent, args, context, info);
 5    } catch (err) {
 6      context.logger.error(err);
 7      throw err;
 8    }
 9  };
10}

5. Composing Resolvers with Middleware

Modularization lets us use composition:

js
 1const { getUserById } = require('./db/user');
 2const requireAuth = require('./middleware/requireAuth');
 3const withErrorHandler = require('./middleware/withErrorHandler');
 4
 5const userResolver = withErrorHandler(
 6  requireAuth(async (parent, { id }, context) => {
 7    context.logger.log('Fetching user:', id);
 8    return await getUserById(id); // separate, testable function
 9  }),
10);
11
12// In the main resolver:
13const resolvers = {
14  Query: {
15    user: userResolver,
16  },
17};

Visualizing the Resolver Flow (Mermaid)

The diagram below illustrates the modular resolver flow above:

MERMAID
graph TD
  A[Client Query] --> B[Resolver Utama]
  B --> C[withErrorHandler]
  C --> D[requireAuth]
  D --> E[Mengambil Data dari DB]
  E --> F[Return Data]
  C --error--> G[Logger Error]

Query Simulation

To make it more concrete, let’s simulate the following cases:

  • A request from a user who isn’t logged in
  • A request for a user id that doesn’t exist
  • A successful request

Test code (pseudo):

js
 1const context = { user: null, logger: console }; // not logged in
 2try {
 3  await resolvers.Query.user(null, { id: 1 }, context);
 4} catch (err) {
 5  console.log('Result:', err.message); // --> Unauthorized
 6}
 7
 8context.user = { id: 12, name: 'Alice' };
 9try {
10  await resolvers.Query.user(null, { id: '99999' }, context);
11} catch (err) {
12  console.log('Result:', err.message); // --> User not found
13}
14
15context.user = { id: 1, name: 'Bob' };
16try {
17  const user = await resolvers.Query.user(null, { id: 1 }, context);
18  console.log('Result:', user); // user data fetched successfully
19} catch (err) {}

Table: Reusable Resolver Format

PartModularization
AuthenticationrequireAuth middleware
LoggingSeparate helper/logger
DB QuerygetUserById helper, etc.
Error HandlingMiddleware/error-handler
Loader/CacheDataLoader, etc.
Field-resolver casesReusable helpers at the field level

Reusable Resolver Patterns

  1. Resolver Maker: Create a factory function that builds resolvers when you have many similar schemas.
    js
     1function makeGetByIdResolver(getterFn) {
     2  return async (_, { id }) => await getterFn(id);
     3}
     4
     5const resolvers = {
     6  Query: {
     7    user: makeGetByIdResolver(getUserById),
     8    product: makeGetByIdResolver(getProductById),
     9  },
    10};
  2. Use a Utility Library: Use a package like graphql-middleware for a middleware pipeline;
  3. Generic Error Handler: Implement a centralized error handler.

When to Go Modular, and When Not To?

Over-engineering is the engineer’s enemy. When a schema is small and stays unchanged for a long time, the argument for making things “clean” can be set aside—maintainable is what matters more. But once the team and codebase grow, clean & reusable resolvers aren’t just a “bonus”—they’re a necessity!

Conclusion

Writing 89 clean and reusable resolvers? The key is: modularization, DRY, and don’t hesitate to use middleware! Beyond making the codebase easy to maintain and grow as a team, the quality of your backend product is guaranteed to level up. With tools like DataLoader, the middleware pattern, and simple helpers/factories, a healthy resolver stack is an inevitability, not a dream. Build it once, enjoy it forever.


References

Hopefully this article serves as a reference and roadmap for your team that wants to make its GraphQL codebase more professional. If you have other pattern tips, share them in the comments! 🚀

Related Articles

💬 Comments