Fourth in a series about migrating from legacy architectures to a modern Nuxt 4 stack.
The Problem: Data Lives in Different Systems
In large enterprise applications, it is common to merge multiple GraphQL schemas into a single stitched schema to get co-location: multiple data sources exposed through one endpoint. Schema stitching alone does not give you cross-referencing: using data from one subgraph as input to another.
Cross-references are everywhere in typical systems:
- A product ID maps to a CMS entry.
- An order status maps to a marketing label.
- A user’s region maps to a localized FAQ entry.
Each backing system evolves independently, so shapes and identifiers rarely line up perfectly.
Without cross-subgraph resolution, the frontend is forced to:
- Query subgraph A for the product
- Extract the product ID
- Transform it (e.g., into a CMS slug)
- Query subgraph B with the transformed value
- Manually merge the results
The @delegate directive removes steps 2–5 by declaring the relationship directly in the schema.
flowchart LR
A[Client] -->|Single GraphQL Query| G[Stitched Gateway]
subgraph SubgraphA[Subgraph A]
PAPI[Product API]
end
subgraph SubgraphB[Subgraph B]
CMS[CMS API]
end
G -->|"1. product(id: '123')"| PAPI
PAPI -->|"Product data\n(id, productName, price)"| G
G -->|"@delegate using productName → slug"| CMS
CMS -->|CMS content for slug| G
G -->|Merged response\nProduct + CMS fields| A
Anatomy of a @delegate Declaration
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.
flowchart LR
PM["ProductModel (core subgraph)"]
CMSRT["RichTextContent (CMS subgraph)"]
PM -- has field --> cms[cmsDescription]
cms -. virtual field\n(only in stitched schema) .-> PM
subgraph DelegateConfig["@delegate configuration"]
TO["to: 'content'"]
FIELD["field: 'productContent'"]
ARGS["args: { slug: '{productName:slug}' }"]
end
PM -->|productName| ARGS
ARGS -->|slugified productName| CMSRT
CMSRT -.- NOTE1["CMS subgraph owns RichTextContent"]
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
}
}
}
flowchart TB
C[Client] -->|Query with cmsDescription| GW[GraphQL Gateway]
subgraph Step1[Step 1: Resolve non-delegated fields\nfrom core API]
GW -->|"product(id: '123')"| CORE[Core Subgraph]
CORE -->| productName,\nprice | GW
end
subgraph Step2["Step 2: Process @delegate\nfor cmsDescription"]
GW -->|Read productName\nApply :slug| SLUG[Slug formatter]
SLUG -->|'premium-plan'| DELEGATE["Build delegated query\nproductContent(slug: 'premium-plan')"]
end
subgraph Step3[Step 3: Execute delegated query\nagainst CMS]
DELEGATE --> CMS[CMS Subgraph]
CMS -->| title,\nbody | GW
end
subgraph Step4[Step 4: Merge into parent response]
GW --> RESP[Final product object\nwith 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
Placeholders come in three flavors, each with distinct data flow semantics.
String Placeholders: {fieldName:formatter}
The most common form. 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)
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
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 makes the origin explicit: this value comes from a field on the parent object.
Parameter Placeholders: p_paramName
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 signals: this value comes from a query parameter.
flowchart LR
subgraph ParentObject[Parent Object]
PF[productName: String]
CF[categoryId: Int]
end
subgraph QueryArgs[Query Arguments]
QAID[id: ID!]
QAPREV[preview: Boolean]
end
subgraph Placeholders[Placeholder Types]
SP["{productName:slug}"]
FP["f_categoryId"]
PP["p_preview"]
end
PF -->|read as string\n+ slugify| SP
CF -->|preserve type Int| FP
QAPREV -->|preserve type Boolean| PP
SP -->|String| Target1[Arg slug: String]
FP -->|Int| Target2[Arg id: Int]
PP -->|Boolean| Target3[Arg preview: Boolean]
The Naming Convention Is the Documentation
Placeholder Cheat Sheet:
┌──────────────────┬───────────────┬───────────────┐
│ Syntax │ Source │ Type │
├──────────────────┼───────────────┼───────────────┤
│ {fieldName} │ Parent field │ Always String │
│ {fieldName:fmt} │ Parent field │ Always String │
│ f_fieldName │ Parent field │ Preserved │
│ p_paramName │ Query argument│ Preserved │
└──────────────────┴───────────────┴───────────────┘
The prefixes effectively serve as inline documentation for how data flows through your delegated calls.
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 may send 50 individual queries to the target subgraph:
N+1 Problem:
Client: products { name, cmsDescription { title } }
Step 1: Fetch 50 products from core subgraph
Step 2: For EACH product, delegate to CMS:
productContent(slug: "product-1")
productContent(slug: "product-2")
...
productContent(slug: "product-50")
Total: 1 + 50 = 51 queries ← expensive
There are three main mitigation strategies.
flowchart TB
C[Client] -->|products name, cmsDescription | GW[Gateway]
subgraph N1[N+1 Delegation]
GW -->|1 query| CORE[Core Subgraph\nproducts list]
CORE -->|50 products| GW
GW -->|50 delegated calls\none per product| CMS[CMS Subgraph]
end
CMS -->|50 CMS responses| GW
GW -->|Merged list response| C
N1 -.- NOTE1["Total: 1 core + 50 CMS = 51 queries"]
Mitigation 1: Batch Arguments
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. This requires the target subgraph to expose fields that accept array arguments.
flowchart TB
C[Client] -->|products descriptions | GW[Gateway]
subgraph CoreCall[Step 1: Fetch list]
GW --> CORE[Core Subgraph\nproducts list]
CORE -->|50 products\nwith productSlugs| GW
end
subgraph BatchDelegate[Step 2: Batched delegation]
GW -->|"Collect slugs\n slug-1..slug-50"| AGG[Aggregate slugs]
AGG -->|"productContents(slugs: [...])"| CMS[CMS Subgraph]
CMS -->|"50 RichTextContent"| GW
end
GW -->|Attach descriptions to products| C
BatchDelegate -.- NOTE2["Total: 1 core + 1 CMS = 2 queries"]
Mitigation 2: DataLoader Pattern
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
The schema stays the same; the optimization lives in the gateway’s execution layer.
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. Delegation only runs if the client selects the delegated field. A list view that asks for only name and price never triggers 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 puts control directly in the client’s hands.
flowchart LR
C1["Client A\n(products { name, price })"]
C2["Client B\n(products { name, price, cmsDescription })"]
C1 --> GW[Gateway]
C2 --> GW
GW -->|Query core only| CORE[Core Subgraph]
GW -->|Query core + delegate to CMS\nonly for Client B| CMS[CMS Subgraph]
CORE --> GW
CMS --> GW
GW -->|No delegation performed| C1
GW -->|Delegation performed| C2
Testing Delegated Fields
Delegated fields span multiple subgraphs, which complicates testing. A layered strategy helps:
| 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 |
Each level catches a different class of issues, from formatting bugs to wiring mistakes to real-world data mismatches.
flowchart TB
subgraph Unit[Unit Tests]
U1[Test slug formatter]
U2[Test lower/upper formatter]
end
subgraph Integration[Integration Tests]
I1[Mock core subgraph]
I2[Mock CMS subgraph]
I3[Verify merged response\nfor delegated fields]
end
subgraph E2E[E2E Tests]
E1[Run against real stitched schema]
E2[Assert data across\nall backing systems]
end
U1 --> Integration
U2 --> Integration
Integration --> E2E
Lessons Learned
Delegation is a schema design pattern, not just a directive
@delegate is syntactic sugar over a deeper idea: expressing cross-system data relationships in the schema itself. Instead of scattering join logic across multiple frontends, you centralize it in your schema, making it declarative, discoverable, and reusable.
Type-preserving placeholders prevent subtle bugs
Passing a Boolean as "true" means the target resolver receives the wrong type and may behave unexpectedly. f_ and p_ placeholders preserve the original types end-to-end, eliminating an entire class of subtle bugs.
N+1 delegation is the biggest risk
On list fields, naive delegation scales linearly with list size. Unless you batch, query counts explode. Design target fields with batch arguments where possible, and use DataLoader-style batching in the gateway to keep performance under control.
What’s Next
- Article 18: Building a Headless Design System in Vue 3 — The Compose Pattern — Separating style logic from templates for testable, composable components.
- Article 19: A/B Testing at the SSR Level — Cookie-Based Variant Selection — How to serve different content variants during server rendering.
- Article 20: Live Preview with a Headless CMS — Real-Time WYSIWYG Editing in SSR — Making the CMS preview iframe work with server-side rendering.
Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.





