Skip to content

feat(@nestjs/graphql): add @BatchResolveField() for DataLoader-backed batching - #4123

Open
Manuel-Antunes wants to merge 1 commit into
nestjs:masterfrom
Manuel-Antunes:feat/batch-resolve-field
Open

Manuel-Antunes wants to merge 1 commit into
nestjs:masterfrom
Manuel-Antunes:feat/batch-resolve-field

Conversation

@Manuel-Antunes

@Manuel-Antunes Manuel-Antunes commented Sep 8, 2026

Copy link
Copy Markdown

Motivation

Spring for GraphQL ships @BatchMapping: you annotate a controller method, it receives every parent of the current execution layer at once, and the framework wires the batching for you. The n+1 problem is solved declaratively, at the same level of abstraction you already write resolvers at.

Nest has no equivalent today. Solving n+1 means hand-rolling DataLoaders: instantiating them per request, threading them through the GraphQL context or making the resolver request-scoped, and re-implementing the positional key/value mapping in every field. That is boilerplate the framework is well positioned to absorb — and the ecosystem has repeatedly said so:

The standing answer has been "use dataloader with request-scoped providers". That works, but it has a real cost: making a resolver request-scoped bubbles up its entire dependency tree, which is a performance trade-off users take on solely to get a per-request loader. @BatchResolveField() gives the same guarantee with no scope change at all — the loader lives in a WeakMap keyed by the GraphQL context object.

Worth noting: this has been asked for repeatedly but, as far as I can tell from searching the repo history, never actually submitted as a PR. This is an attempt to put an implementation on the table.

What this is not

  • Not a breaking change. Purely additive. No existing decorator, interface, or behavior changes. @ResolveField() is untouched; the batching branch in ResolversExplorerService only activates when the new metadata key is present.
  • Not a new dependency. dataloader is an optional peer dependency, resolved through a dynamic import() exactly like the existing ts-morph integration. Users who never write a batch field never install it, and nothing in the published .d.ts references its types — the loader options are declared as a structural subset in batch-loader-options.interface.ts.
  • Not driver-specific. The wiring lives in @nestjs/graphql, above the driver abstraction. E2E tests cover Apollo and Mercurius, code first and schema first.

API

@Resolver(() => Post)
export class PostResolver {
  @BatchResolveField(() => [Comment])
  async comments(@Parent() posts: Post[]): Promise<Map<Post, Comment[]>> {
    const comments = await this.service.findByPostIds(posts.map((p) => p.id));
    return new Map(
      posts.map((post) => [
        post,
        comments.filter((c) => c.postId === post.id),
      ]),
    );
  }
}

The method may return:

  • Map<Parent, T> — keyed by the parent objects (the Spring equivalent of relying on equals/hashCode)
  • Map<K, T> with the keyBy option — because JS Map compares object keys by reference, keyBy is how you say "look this parent up by its id", which is what your repository has probably already grouped by
  • T[] — one entry per parent, in order

Options are everything @ResolveField() accepts (name, nullable, description, deprecationReason, complexity, middleware) plus keyBy and dataLoader (forwarded to the underlying DataLoader: maxBatchSize, cache, cacheKeyFn, batchScheduleFn, cacheMap).

One deliberate departure from Spring

Spring's @BatchMapping forbids field arguments, and sends you to BatchLoaderRegistry if you need them. Here, loaders are partitioned per (request context, serialized arguments), so this batches correctly into two calls instead of erroring:

{
  posts {
    published: comments(status: APPROVED) { text }
    pending: comments(status: PENDING) { text }
  }
}

Implementation notes

  • Where it hooks in (resolvers-explorer.service.ts): batching sits between the Nest resolver context and the field middleware. Field middleware therefore still runs once per field, while the method runs once per batch. Enhancers enabled via fieldResolverEnhancers sit inside the batch and run once per batch, with the array of parents as the execution-context root. Both behaviors are covered by tests.
  • Loader storage: a per-registration WeakMap<contextObject, Map<argsKey, DataLoader>> in the closure, rather than writing onto the user's context object. No key collisions between resolvers, no trouble with frozen contexts, and loaders are garbage-collected with the request.
  • Type function: required in code first (a batch method's reflected return type is Map/Promise, which says nothing about the schema field). The error is thrown at schema-build time, following the @Query() precedent, so schema first works with a bare @BatchResolveField().
  • Missing package: if dataloader is absent, the error is logged at boot and surfaced as a GraphQL error on the field, rather than failing silently.

Tests

38 new tests, all green alongside the existing suite:

  • packages/graphql/tests/utils/batch-field-resolver.util.spec.ts (17) — batching, per-request isolation, args partitioning, memoization, maxBatchSize, whole-batch and per-parent errors, fallbacks
  • packages/graphql/tests/decorators/batch-resolve-field.decorator.spec.ts (7) — metadata and overloads
  • packages/apollo/tests/e2e/ (11) — code first, schema first, field middleware ordering, enhancer semantics, fast-path resolvers
  • packages/mercurius/tests/e2e/code-first-batch.spec.ts (3) — driver independence

Note on process

CONTRIBUTING.md asks for a [discussion] issue before a major feature. Given the four issues above already cover the request, I went straight to an implementation so there is something concrete to react to — but I am happy to move this to a discussion issue and iterate on the API (naming, keyBy, argument handling) before any of it is considered for merge.

Docs PR: nestjs/docs.nestjs.com#3527

- Introduced the @BatchResolveField decorator to enable batch field resolution in GraphQL.
- Added support for batch loading of related data, reducing N+1 query problems.
- Created a new GraphQL schema for blog posts, authors, and comments.
- Implemented PostsResolver to handle fetching posts, authors, and comments using batch resolution.
- Developed BlogService to manage data retrieval and batch call recording.
- Added tests for the new batch resolution functionality, ensuring correct behavior and performance.
- Updated existing utilities and interfaces to support batch loading options and error handling.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant