The `@delegate` Directive Deep Dive — Cross-Subgraph Field Resolution

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:

  1. Query subgraph A for the product
  2. Extract the product ID
  3. Transform it (e.g., into a CMS slug)
  4. Query subgraph B with the transformed value
  5. 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:

PartMeaning
extend type ProductModelExtends a type coming from a backend subgraph
cmsDescriptionA new virtual field added only in the stitched schema
RichTextContentThe 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:

LevelWhat It TestsHow
UnitPlaceholder formattingTest :slug, :lower, :upper and other formatters in isolation
IntegrationCross-subgraph resolutionMock both subgraphs, verify the merged response shape and values
E2EFull pipelineQuery 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.

Categories: