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

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

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:

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.


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

SyntaxSourceType
{fieldName}Parent field or arg (args take precedence)Always String
{fieldName:fmt}Parent field or arg (args take precedence)Always String
f_fieldNameParent field onlyPreserved
p_paramNameQuery argument onlyPreserved

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:

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

In this model, each level targets a different class of issues, from formatting bugs to wiring mistakes to real-world data mismatches.


Further Reading


What’s Next

  • Article 5: GraphQL-Based Code Generation — Eliminating All Boilerplate — How writing a .graphql file 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

Categories: