Describe the bug
When querying a list of entities that have relation-type custom fields, Vendure runs one database query per entity to resolve each relation — the classic N+1 problem. There is no way for plugin authors to optimize this because the auto-generated custom field resolvers silently override any @ResolveField a plugin defines.
To Reproduce
- Define a relation custom field on an entity used in list queries:
config.customFields.OrderLine.push({
name: 'subItems',
type: 'relation',
entity: SubItem,
list: true,
});
- Query a list that includes the relation:
query {
activeOrder {
lines {
customFields {
subItems { id name }
}
}
}
}
- Observe database queries — each order line triggers its own query to resolve
subItems.
Expected behavior
Relation custom fields should be resolved efficiently in list contexts, either by batching queries internally or by allowing plugin authors to plug in their own DataLoader.
Actual behavior
CustomFieldRelationResolverService.resolveRelation() runs a subquery + main query per entity per relation field. For 50 order lines with one relation custom field, that's 51 queries instead of 2-3.
Two expected escape routes don't work:
-
eager: true on the custom field config — maps to TypeORM's eager decorator option, but ListQueryBuilder uses QueryBuilder + setFindOptions(). While setFindOptions does call joinEagerRelations(), that method walks metadata.eagerRelations on the root entity. Custom field relations live on the embedded customFields entity metadata, which joinEagerRelations doesn't recurse into. Meanwhile, joinTreeRelationsDynamically explicitly skips non-tree, non-self-referencing relations. So eager: true has no practical effect on list queries.
-
Plugging in a DataLoader via @ResolveField — The auto-generated resolvers on {Entity}CustomFields silently override plugin resolvers. NestJS GraphQL merges resolvers using a shallow spread (extend(typesResolvers, options.resolvers)), and the programmatic resolver map from generateResolvers() replaces the entire type object from decorator-based resolvers.
Workaround
The only current workaround is to mark the custom field as internal: true, then re-add the field as a schema extension on the parent entity with a @ResolveField + DataLoader. This works but requires maintaining two representations of the same field.
Docs note
The DataLoader docs state:
"CustomField relations are automatically added to the root query for the entity that they are part of."
This is not accurate for list queries. The relation is resolved lazily per entity by the auto-generated resolver.
Proposed solution
Add DataLoader-style batching inside CustomFieldRelationResolverService. The resolveRelation() method would delegate to a request-scoped DataLoader (keyed by request context + entity name + field name) that collects all entity IDs in a single tick and runs one batched query with WHERE entity.id IN (:...ids).
This is a contained change to one file. The auto-generated resolvers in generate-resolvers.ts continue calling resolveRelation() the same way — they just get batched results instead of individual queries. No plugin code changes needed.
Prior art: commit 7b57d28 added a DataLoader for the Collection → ProductVariants N+1.
Environment
- @vendure/core version: latest (master)
- Affects all 26+ entity types that support custom fields
Relevant files
packages/core/src/api/common/custom-field-relation-resolver.service.ts — per-entity query (lines 36-57)
packages/core/src/api/config/generate-resolvers.ts — auto-generated resolvers (lines 239-267)
packages/core/src/service/helpers/list-query-builder/list-query-builder.ts — uses QueryBuilder
packages/core/src/service/helpers/utils/tree-relations-qb-joiner.ts — skips non-tree relations (lines 100-108)
packages/core/src/entity/register-custom-entity-fields.ts — eager config registration (lines 45-56)
Describe the bug
When querying a list of entities that have
relation-type custom fields, Vendure runs one database query per entity to resolve each relation — the classic N+1 problem. There is no way for plugin authors to optimize this because the auto-generated custom field resolvers silently override any@ResolveFielda plugin defines.To Reproduce
subItems.Expected behavior
Relation custom fields should be resolved efficiently in list contexts, either by batching queries internally or by allowing plugin authors to plug in their own DataLoader.
Actual behavior
CustomFieldRelationResolverService.resolveRelation()runs a subquery + main query per entity per relation field. For 50 order lines with one relation custom field, that's 51 queries instead of 2-3.Two expected escape routes don't work:
eager: trueon the custom field config — maps to TypeORM's eager decorator option, butListQueryBuilderusesQueryBuilder+setFindOptions(). WhilesetFindOptionsdoes calljoinEagerRelations(), that method walksmetadata.eagerRelationson the root entity. Custom field relations live on the embeddedcustomFieldsentity metadata, whichjoinEagerRelationsdoesn't recurse into. Meanwhile,joinTreeRelationsDynamicallyexplicitly skips non-tree, non-self-referencing relations. Soeager: truehas no practical effect on list queries.Plugging in a DataLoader via
@ResolveField— The auto-generated resolvers on{Entity}CustomFieldssilently override plugin resolvers. NestJS GraphQL merges resolvers using a shallow spread (extend(typesResolvers, options.resolvers)), and the programmatic resolver map fromgenerateResolvers()replaces the entire type object from decorator-based resolvers.Workaround
The only current workaround is to mark the custom field as
internal: true, then re-add the field as a schema extension on the parent entity with a@ResolveField+ DataLoader. This works but requires maintaining two representations of the same field.Docs note
The DataLoader docs state:
This is not accurate for list queries. The relation is resolved lazily per entity by the auto-generated resolver.
Proposed solution
Add DataLoader-style batching inside
CustomFieldRelationResolverService. TheresolveRelation()method would delegate to a request-scoped DataLoader (keyed by request context + entity name + field name) that collects all entity IDs in a single tick and runs one batched query withWHERE entity.id IN (:...ids).This is a contained change to one file. The auto-generated resolvers in
generate-resolvers.tscontinue callingresolveRelation()the same way — they just get batched results instead of individual queries. No plugin code changes needed.Prior art: commit
7b57d28added a DataLoader for the Collection → ProductVariants N+1.Environment
Relevant files
packages/core/src/api/common/custom-field-relation-resolver.service.ts— per-entity query (lines 36-57)packages/core/src/api/config/generate-resolvers.ts— auto-generated resolvers (lines 239-267)packages/core/src/service/helpers/list-query-builder/list-query-builder.ts— uses QueryBuilderpackages/core/src/service/helpers/utils/tree-relations-qb-joiner.ts— skips non-tree relations (lines 100-108)packages/core/src/entity/register-custom-entity-fields.ts— eager config registration (lines 45-56)