Why Your GraphQL Resolvers Should Be Boring

Stop writing complex GraphQL resolvers. Discover how keeping resolvers boring can improve your GraphQL architecture.

Modern GraphQL codebases are getting increasingly complex with custom directives, middleware chains, and complex resolver patterns. Here's why keeping your resolvers boring might be the best architectural decision you'll make.

The Tempting Path

// Looks clever, but adds complexity
const userResolver = {
  Query: {
    user: async (_, { id }, context) => {
      const user = await context.dataSources.users.getUser(id);
      await context.metrics.trackQuery('user', { id });
      await context.cache.set(`user:${id}`, user);
      return applyUserTransforms(user);
    }
  }
}

The Better Way: Boring Resolvers

1. Keep Resolvers Thin

// ✅ Clean and maintainable
const userResolver = {
  Query: {
    user: (_, { id }, { userService }) => 
      userService.getUser(id)
  },
  User: {
    posts: (user, _, { postService }) => 
      postService.getPostsByUser(user.id)
  }
}

// Business logic belongs in services
class UserService {
  constructor(
    private readonly repo: UserRepository,
    private readonly metrics: MetricsService,
    private readonly cache: CacheService
  ) {}

  async getUser(id: string): Promise<User> {
    const cached = await this.cache.get(`user:${id}`);
    if (cached) return cached;

    const user = await this.repo.findById(id);
    await this.metrics.trackQuery('user', { id });
    await this.cache.set(`user:${id}`, user);
    
    return user;
  }
}

2. Standardize Error Handling

// Define clear error types
class NotFoundError extends Error {
  constructor(resource: string, id: string) {
    super(`${resource} with id ${id} not found`);
    this.code = 'NOT_FOUND';
  }
}

// Central error handling
const errorHandler = (error: Error) => {
  if (error instanceof NotFoundError) {
    return {
      code: error.code,
      message: error.message,
      status: 404
    };
  }
  // Handle other error types...
};

// Clean resolver
const userResolver = {
  Query: {
    user: async (_, { id }, { userService }) => {
      try {
        return await userService.getUser(id);
      } catch (error) {
        throw errorHandler(error);
      }
    }
  }
}

3. Use DataLoader for Batching

class UserDataLoader {
  private loader = new DataLoader(async (ids: string[]) => {
    const users = await this.userRepo.findByIds(ids);
    return ids.map(id => 
      users.find(user => user.id === id) || null
    );
  });

  load(id: string) {
    return this.loader.load(id);
  }
}

// Simple resolver using DataLoader
const postResolver = {
  Post: {
    author: (post, _, { userLoader }) =>
      userLoader.load(post.authorId)
  }
}

Anti-Patterns to Avoid

1. Business Logic in Resolvers

// ❌ BAD: Logic spread across resolvers
const userResolver = {
  Query: {
    user: async (_, { id }, context) => {
      const user = await getUser(id);
      if (context.user.role !== 'ADMIN') {
        delete user.email;
        delete user.phoneNumber;
      }
      return user;
    }
  }
}

// ✅ BETTER: Encapsulated in service
class UserService {
  async getUser(id: string, viewerRole: string): Promise<User> {
    const user = await this.repo.findById(id);
    return this.applyPermissions(user, viewerRole);
  }

  private applyPermissions(user: User, role: string): User {
    if (role === 'ADMIN') return user;
    return this.sanitizeUserData(user);
  }
}

2. Complex Field-Level Logic

// ❌ BAD: Complex computed fields
const userResolver = {
  User: {
    fullName: user => `${user.firstName} ${user.lastName}`,
    age: user => calculateAge(user.birthDate),
    status: async user => {
      const lastLogin = await getLastLogin(user.id);
      const subscription = await getSubscription(user.id);
      return determineStatus(lastLogin, subscription);
    }
  }
}

// ✅ BETTER: Move to domain model
class User {
  constructor(private readonly data: UserData) {}

  get fullName() {
    return `${this.data.firstName} ${this.data.lastName}`;
  }

  get age() {
    return calculateAge(this.data.birthDate);
  }

  async getStatus(services: Services) {
    const [lastLogin, subscription] = await Promise.all([
      services.getLastLogin(this.data.id),
      services.getSubscription(this.data.id)
    ]);
    return this.determineStatus(lastLogin, subscription);
  }
}

Best Practices

  1. Resolver Structure
// Keep it flat and predictable
const resolvers = {
  Query: {
    entity: (_, args, context) => 
      context.services.entity.get(args)
  },
  Mutation: {
    updateEntity: (_, args, context) =>
      context.services.entity.update(args)
  },
  Entity: {
    subEntity: (parent, _, context) =>
      context.services.subEntity.getByParent(parent.id)
  }
}
  1. Context Structure
interface Context {
  // Services for business logic
  services: {
    user: UserService
    post: PostService
    comment: CommentService
  }
  // DataLoaders for efficient data fetching
  loaders: {
    user: UserDataLoader
    post: PostDataLoader
    comment: CommentDataLoader
  }
  // Request-specific data
  request: {
    user?: AuthenticatedUser
    headers: Record<string, string>
    ip: string
  }
}

Conclusion

Boring resolvers lead to:

  • Better maintainability
  • Clearer separation of concerns
  • Easier testing
  • More predictable behavior
  • Better performance optimization opportunities

Remember: The goal of a resolver is to resolve data, not to implement business logic.