Skip to content

Conversation

@tomoikey
Copy link
Contributor

@tomoikey tomoikey commented Jan 25, 2026

Goal

This series adds first-class batch resolver support to gqlgen, letting you solve the GraphQL N+1 problem without external libraries.
The N+1 problem happens when a single query fans out into many individual DB/API calls, killing performance. Today most people solve this with dataloader libraries; this makes it possible to handle it in gqlgen directly.
Since the behavior is opt-in and doesn't affect existing users, shipping it as experimental still provides clear value.

This PR alone might not give you the full picture, so take a look at the _examples/batchresolver directory on the performance-enhancement-4 branch (#4008). Checking the test files, generated.go, and schema.resolvers.go should make the difference from existing non-batch resolvers clear. The output itself doesn't change, and there are tests to prove it.

Configuration and generated resolver shape

To enable batch resolvers, mark fields explicitly in the config:

models:
  User:
    fields:
      posts:
        resolver: true
        batch: true # <- new!!!

When batch: true is set, gqlgen generates a batch resolver method that receives multiple parents and returns per‑parent results:

// PostsBatch is the batch resolver for the posts field.
func (r *userResolver) PostsBatch(ctx context.Context, objs []*User) []BatchResult[[]*Post]

If batch is not set, gqlgen generates the standard per‑object resolver:

func (r *userResolver) Posts(ctx context.Context, obj *User) ([]*Post, error)

Only fields with batch: true are affected. All other resolvers remain unchanged.

Preconditions and approach

  • Nothing is enabled unless batch: true is set
    • This is fully opt‑in, so existing users are unaffected.
  • Same error handling, partial response behavior, and non‑null propagation as non‑batch
    • These invariants are covered by tests to prevent behavioral drift.
  • PRs are split in dependency order so reviewers can follow the whole story linearly
    • Settings → internal data model → stub generation → runtime logic and tests.

Role of each PR

performance‑enhancement‑1 branch

#4005

Allow batch to be declared in config

  • Add batch: true to TypeMapField
  • Make batch‑enabled fields explicit in schema config

performance‑enhancement‑2 branch based on performance‑enhancement‑1

#4006

Propagate the batch flag into codegen

  • Add Batch flag to Field
  • Let templates and generators branch correctly on batch vs non‑batch

performance‑enhancement‑3 branch based on performance‑enhancement‑2

#4007

Generate batch resolver stubs

  • Auto‑generate batch resolver method stubs

performance‑enhancement‑4 branch based on performance‑enhancement‑3

#4008

Add runtime support and verification

  • Implement batch execution logic
  • Add example tests comparing batch vs non‑batch behavior
  • Preserve batch implementations across regeneration

Describe your PR and link to any relevant issues.

I have:

  • Added tests covering the bug / feature (see testing)
  • Updated any relevant documentation (see docs)

@tomoikey tomoikey changed the title Performance enhancement 3 feat(resolvergen): add batch resolver stub generation Jan 25, 2026
@coveralls
Copy link

coveralls commented Jan 25, 2026

Coverage Status

coverage: 0.0%. remained the same
when pulling 8700b25 on tomoikey:performance-enhancement-3
into 74110e2 on 99designs:master.

Comment on lines 13 to 36
// Users is the resolver for the users field.
func (r *queryResolver) Users(ctx context.Context) ([]*User, error) {
panic(fmt.Errorf("not implemented: Users - users"))
}

// NullableBatchBatch is the batch resolver for the nullableBatch field.
func (r *userResolver) NullableBatchBatch(ctx context.Context, objs []*User) ([]*Profile, []error) {
panic("not implemented: NullableBatchBatch - nullableBatch")
}

// NullableNonBatch is the resolver for the nullableNonBatch field.
func (r *userResolver) NullableNonBatch(ctx context.Context, obj *User) (*Profile, error) {
panic("not implemented")
}

// NonNullableBatchBatch is the batch resolver for the nonNullableBatch field.
func (r *userResolver) NonNullableBatchBatch(ctx context.Context, objs []*User) ([]*Profile, []error) {
panic("not implemented: NonNullableBatchBatch - nonNullableBatch")
}

// NonNullableNonBatch is the resolver for the nonNullableNonBatch field.
func (r *userResolver) NonNullableNonBatch(ctx context.Context, obj *User) (*Profile, error) {
panic("not implemented")
}
Copy link
Contributor Author

@tomoikey tomoikey Jan 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally, even when batch: true is specified, we would generate both a regular resolver and a batch resolver, allowing gqlgen's executor to intelligently choose between them based on the situation.
However, I have determined that this is difficult to achieve with the current template structure. This is because the marshaler is currently designed to be invoked N times for N elements in an array.
That said, this is not a significant problem. In general, a batch resolver implementation subsumes the non-batch resolver implementation. When processing a single element, we can simply pass it as a single-element array to the batch resolver.
We will leave this optimization as an opportunity for future improvement.

@tomoikey tomoikey changed the title feat(resolvergen): add batch resolver stub generation feat(resolvergen): add batch resolver stub generation (blocked by #4006) Jan 26, 2026
@tomoikey tomoikey force-pushed the performance-enhancement-3 branch from 3c3e5d3 to 8700b25 Compare January 27, 2026 10:20
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.

2 participants