Fourth in a series about migrating from legacy architectures to a modern Nuxt 4 stack. This article assumes familiarity with schema stitching as covered in Article 3: GraphQL Schema Stitching — One API to Rule Them All.
Table of Contents
- The Problem: Co-location Is Not Enough
- Anatomy of a
@delegateDeclaration - The Resolution Pipeline
- Placeholder Types and Data Flow
- Handling N+1 Problems
- Testing Delegated Fields
- What’s Next
The Problem: Co-location Is Not Enough
Schema stitching gives you co-location: multiple data sources exposed through one endpoint and one type system. But it does not automatically give you cross-referencing — the ability to use a value resolved from one subgraph as an input to another.
Cross-references are everywhere in typical systems:
- A product ID from the backend maps to a CMS description keyed by a slug.
- An order status from the order service maps to a marketing label in the content system.
- A user’s region from the profile service maps to a localized FAQ entry in the CMS.
Each backing system evolves independently, so identifiers and naming conventions rarely line up. Without a delegation mechanism, the stitched gateway simply returns whatever each subgraph provides — and the frontend is back to manually fetching the second piece and joining the data by hand.
A custom @delegate directive solves this by letting you declare cross-subgraph relationships directly in the schema extension layer. This article demonstrates how such a directive can be designed and implemented — it is not built into GraphQL, but rather added by your gateway layer on top of delegateToSchema from @graphql-tools/delegate.
Anatomy of a @delegate Declaration
> Important: The @delegate directive is not part of the GraphQL specification. It is a custom directive that your architecture team must implement. The examples in this article show one possible implementation approach built on top of delegateToSchema from @graphql-tools/delegate and Apollo Server — the exact syntax and behavior depend on your gateway’s delegation logic.
extend type ProductModel {
cmsDescription: RichTextContent
@delegate(
to: "content",
field: "productContent",
args: { slug: "{productName:slug}" }
)
}
Each part of this declaration:
| Part | Meaning |
|---|---|
extend type ProductModel | Extends a type coming from a backend subgraph |
cmsDescription | A new virtual field added only in the stitched schema |
RichTextContent | The return type (owned by the CMS subgraph) |
to: "content" | Target subgraph to query |
field: "productContent" | Root field to call on the target subgraph |
args: { slug: "{productName:slug}" } | Arguments, using placeholders from the parent object |
When a query asks for cmsDescription, the gateway intercepts it, reads productName from the resolved ProductModel, applies the :slug formatter, and calls the CMS subgraph with the result.
The Resolution Pipeline
Any query that touches delegated fields runs through a multi-step pipeline:
Client Query:
query {
product(id: "123") {
productName
price
cmsDescription { # delegated field
title
body
}
}
}
%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#1a1a2e', 'primaryTextColor': '#e0e0e0', 'primaryBorderColor': '#e07020', 'lineColor': '#e07020', 'secondaryColor': '#16213e', 'tertiaryColor': '#0f3460', 'edgeLabelBackground': '#1a1a2e', 'clusterBkg': '#16213e', 'clusterBorder': '#e07020' }}}%%
flowchart TB
C[Client] -->|Query with cmsDescription| GW[GraphQL Gateway]
subgraph Step1[Step 1: Resolve non-delegated fields<br/>from core API]
GW -->|"product(id: '123')"| CORE[Core Subgraph]
CORE -->| productName,<br/>price | GW
end
subgraph Step2["Step 2: Process @delegate<br/>for cmsDescription"]
GW -->|Read productName<br/>Apply :slug| SLUG[Slug formatter]
SLUG -->|'premium-plan'| DELEGATE["Build delegated query<br/>productContent(slug: 'premium-plan')"]
end
subgraph Step3[Step 3: Execute delegated query<br/>against CMS]
DELEGATE --> CMS[CMS Subgraph]
CMS -->| title,<br/>body | GW
end
subgraph Step4[Step 4: Merge into parent response]
GW --> RESP[Final product object<br/>with cmsDescription]
end
RESP --> C
The client receives a simple, flat response. It is unaware that two subgraphs were involved or that a string transformation happened in between.
Placeholder Types and Data Flow
One approach is to define three placeholder flavors, each with distinct data flow semantics.
String Placeholders: {fieldName:formatter}
The most common form in this design. It reads a string from the parent object and optionally transforms it:
Source: parent.productName = "Premium Support"
{productName} → "Premium Support" (pass-through)
{productName:slug} → "premium-support" (slugify)
{productName:lower} → "premium support" (lowercase)
{productName:upper} → "PREMIUM SUPPORT" (uppercase)
In this convention, string placeholders always produce strings. They work well for IDs, slugs, and names — but not when the target field expects a Boolean or a number.
Field Placeholders: f_fieldName
In this design, it reads a value from the parent object and preserves its original type:
Source: parent.categoryId = 42 (number)
args: { id: "f_categoryId" } → id: 42 (number, not "42")
The f_ prefix is one way to make the origin explicit: this value comes from a Field on the parent object.
Parameter Placeholders: p_paramName
In this design, it reads from the arguments of the current query, not from the parent object:
GraphQL query:
product(id: "123", preview: true) {
cmsDescription @delegate(..., args: { preview: "p_preview" })
}
Result: preview is passed as Boolean true (not the string "true").
The p_ prefix is chosen here to signal: this value comes from a query Parameter.
One Possible Naming Convention
| Syntax | Source | Type |
|---|---|---|
{fieldName} | Parent field or arg (args take precedence) | Always String |
{fieldName:fmt} | Parent field or arg (args take precedence) | Always String |
f_fieldName | Parent field only | Preserved |
p_paramName | Query argument only | Preserved |
With this convention, the prefixes effectively serve as inline documentation for how data flows through your delegated calls — though your own implementation may use entirely different naming.
Handling N+1 Problems
The biggest trap with delegation is the classic N+1 problem. If a list returns 50 items and each item has a delegated field, the gateway sends one query to the core subgraph to fetch the list, then fires a separate delegated query for each item — 1 + 50 = 51 queries total. Three mitigation strategies follow.
Mitigation 1: Batch Arguments
One option is to design the target subgraph to support batch lookups:
extend type ProductList {
descriptions: [RichTextContent]
@delegate(
to: "content",
field: "productContents",
args: { slugs: "f_productSlugs" }
)
}
With productContents(slugs: [...]), a single call can resolve all 50 entries, reducing 51 queries to 2. This approach requires the target subgraph to expose fields that accept array arguments.
Mitigation 2: DataLoader Pattern
Another option is to implement a DataLoader in the gateway so delegation calls within the same event loop tick are batched automatically:
Without DataLoader: With DataLoader:
tick 1: delegate(slug-1) tick 1: collect(slug-1)
tick 2: delegate(slug-2) collect(slug-2)
tick 3: delegate(slug-3) collect(slug-3)
... ...
tick 50: delegate(slug-50) collect(slug-50)
batch query(slug-1..50)
50 network calls 1 network call
With this approach, the schema stays the same; the optimization lives in the gateway’s execution layer.
%%{init: {'theme': 'dark', 'themeVariables': { 'primaryColor': '#1a1a2e', 'primaryTextColor': '#e0e0e0', 'primaryBorderColor': '#e07020', 'lineColor': '#e07020', 'secondaryColor': '#16213e', 'tertiaryColor': '#0f3460', 'edgeLabelBackground': '#1a1a2e', 'clusterBkg': '#16213e', 'clusterBorder': '#e07020' }}}%%
sequenceDiagram
participant Resolver as Product resolver
participant DL as DataLoader
participant CMS as CMS Subgraph
loop Same event loop tick
Resolver->>DL: load("slug-1")
Resolver->>DL: load("slug-2")
Resolver->>DL: ...
Resolver->>DL: load("slug-50")
end
DL->>CMS: productContent(slugs: ['slug-1',...,'slug-50'])
CMS-->>DL: [Content-1,...,Content-50]
DL-->>Resolver: Map slugs → contents
Mitigation 3: Selective Delegation
Not every list view needs CMS content. In most implementations, delegation only runs if the client selects the delegated field, meaning a list view that asks for only name and price would not trigger delegation.
# This query does NOT trigger delegation:
query { products { name, price } }
# This query DOES trigger delegation:
query { products { name, price, cmsDescription { title } } }
GraphQL’s field selection model means delegation only runs when the client actually requests the delegated field. The Schema Stitching Handbook has working examples of all three mitigation strategies.
Testing Delegated Fields
Delegated fields span multiple subgraphs, which complicates testing. One approach is a layered strategy:
| Level | What It Tests | How |
|---|---|---|
| Unit | Placeholder formatting | Test :slug, :lower, :upper and other formatters in isolation |
| Integration | Cross-subgraph resolution | Mock both subgraphs, verify the merged response shape and values |
| E2E | Full pipeline | Query the stitched schema and assert data from all backing systems is wired correctly |
In this model, each level targets a different class of issues, from formatting bugs to wiring mistakes to real-world data mismatches.
Further Reading
- delegateToSchema — Schema Extensions — The canonical reference for
delegateToSchemafrom@graphql-tools/delegate, with examples of using it inside resolvers and directive implementations. - Schema Stitching docs — Full reference for
@graphql-tools/stitchincludingstitchSchemas, subschema config, and type merging. - Apollo Server documentation — How to host a stitched schema behind Apollo Server.
- Schema Stitching Handbook — Practical cookbook with runnable examples: remote schemas, computed fields, batching, and DataLoader patterns.
- schema-stitching-handbook on GitHub — Complete gateway implementations including
delegateToSchemausage in directive resolvers. - LogRocket: Understanding schema stitching in GraphQL — Walkthrough of
stitchSchemaswith type merging and subschema configuration.
What’s Next
- Article 5: GraphQL-Based Code Generation — Eliminating All Boilerplate — How writing a
.graphqlfile generates a fully typed Vue composable with zero manual work. - Article 6: Architecting Enterprise Nuxt with Custom Modules — How modules enforce real architectural boundaries across the codebase.
- Article 7: Nuxt-Based Code Generation — The GraphQL Toolkit and Typed i18n Modules — Build-time code generation that turns YAML and GraphQL into auto-imported, type-safe APIs.
Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.
Hero image credit: Photo by Jan van der Wolf on Pexels





