The @delegate Directive Deep Dive — One Field, Two Subgraphs
Photo by Jan van der Wolf on Pexels
Part 3 finished with a one-line schema extension that gave a document a description resolved from an entirely different system, and it put off two things: how the gateway actually resolves that, and what happens when the same field turns up on a list of fifty documents. This article does both.
A word of warning first: the directive I’m showing here is not part of GraphQL. It’s one way of building such a thing on top of delegateToSchema from @graphql-tools/delegate. The syntax and the semantics are whatever your gateway implements, which is precisely why they’re worth designing deliberately.
Table of Contents
- The Field That Exists in Neither Schema
- Anatomy of a Delegation Declaration
- How the Gateway Resolves the Field
- Placeholders and the Types They Produce
- The Parent Field the Client Did Not Select
- What Happens on a List of Fifty Documents
- Authorization and Failure on a Delegated Field
- Testing What the Declaration Means
- What the Declaration Buys
- Further Reading
- What’s Next
The Field That Exists in Neither Schema
Stitching gives you co-location: two schemas, one endpoint, one type system. What it doesn’t give you is cross-referencing, which is the ability to take a value resolved from one subgraph and use it as an input to another.
And that gap is where most of the real integration work lives, because systems that evolve independently don’t share identifiers:
- a document’s name in the application database identifies its editorial text in the CMS by slug;
- a document’s state enum maps to an explanatory label maintained by editors;
- a visitor’s region from a profile service selects a localized help entry.
Without a delegation mechanism, the stitched gateway returns whatever each subgraph happens to know and not a thing more. So the document type has no description field, and the frontend is right back to fetching the second piece and joining it itself, which is the situation Part 3 opened with. A delegated field closes that gap by declaring the relationship once, where the schema gets assembled, instead of restating it in every single component that needs it.
Anatomy of a Delegation Declaration
So here’s the declaration from Part 3 again, this time as the thing being explained rather than the thing being promised:
extend type DocumentModel {
description: RichTextContent
@delegate(
to: "content",
field: "documentContent",
args: { slug: "{name:slug}" }
)
}
| Part | Meaning |
|---|---|
extend type DocumentModel |
Adds to a type owned by the .NET API subgraph |
description |
A field that exists only in the composed schema |
RichTextContent |
Return type, owned by the content subgraph |
to: "content" |
Which subgraph resolves it |
field: "documentContent" |
Which root field to call there |
args: { slug: "{name:slug}" } |
Arguments, built from the parent object using placeholders |
Everything interesting is in that last row. {name:slug} says: take the name field off the parent document, apply the slug transformation to it, and pass the result in as the slug argument. The gateway has to interpret that string at execution time, which means the placeholder syntax is a small language that your team now owns, with all the usual obligations that come with owning a language: you have to define what it does when the input is null, when the formatter doesn’t exist, and when the parent field was never fetched in the first place.
How the Gateway Resolves the Field
Let’s look at a client query that selects both native and delegated fields:
query {
document(id: "42") {
name
revision
description { # delegated
title
body
}
}
}
Resolution then happens in four steps, alternating between network calls and work local to the gateway:
The client gets a single object back and never finds out that two systems and a string transformation were involved. Hiding all that is the whole point of the mechanism, and it’s also why the next four sections exist: each one of them covers a consequence the client has no way of seeing.
Placeholders and the Types They Produce
A placeholder syntax that only ever produces strings will fall over the first time a target field expects a number or a Boolean, and it’ll fall over as a thoroughly confusing GraphQL validation error rather than an obvious one. One convention that works distinguishes three flavours, by where the value comes from and whether its type survives the trip.
String placeholders read a value and always produce a string, optionally transformed on the way:
parent.name = "Quarterly Report"
{name} → "Quarterly Report" (pass-through)
{name:slug} → "quarterly-report"
{name:lower} → "quarterly report"
{name:upper} → "QUARTERLY REPORT"
Field placeholders read a field off the parent and preserve its type, which matters a lot for IDs and numbers:
parent.collectionId = 42 (number)
args: { id: "f_collectionId" } → id: 42 (number, not "42")
Parameter placeholders read from the arguments of the current query rather than from the parent object:
document(id: "42", preview: true) {
description # declared with args: { preview: "p_preview" }
}
Here preview arrives at the content subgraph as the Boolean true rather than the string "true", and that’s the difference between a working preview and a CMS that quietly hands you published content instead.
| Syntax | Source | Resulting type |
|---|---|---|
{field} / {field:fmt} |
Parent field or query argument | Always String |
f_field |
Parent field only | Preserved |
p_param |
Query argument only | Preserved |
Those prefixes act as inline documentation: reading a declaration tells you straight away whether a value came from the parent object or from the caller, and that’s exactly the question you’ll have when a delegated field starts returning null in production. Any convention works as long as it answers that question and gets applied consistently. The point I’m making is that placeholder resolution has type semantics, and if nobody writes those down somewhere, then somebody is going to lose an afternoon rediscovering them from a failing query.
The Parent Field the Client Did Not Select
There’s a rather subtle correctness problem hiding in the walkthrough above. The client selected name, so the gateway had a name to slugify. But what if it hadn’t?
query {
document(id: "42") {
description { title } # name is never selected
}
}
A naive implementation queries the API subgraph for exactly what the client asked for, gets back a document object with no name on it, resolves the placeholder to undefined, and then calls the CMS with a nonsense slug. What you get is a null description that looks for all the world like missing content rather than a bug in your gateway.
So a delegated field has a dependency on parent fields, and the gateway has to add those to the selection set, meaning the list of fields in the query it sends upstream, whether the client selected them or not. The declaration says “to resolve description I need name”, and the gateway quietly appends name to the document query, uses it, and then leaves it out of the response the client sees. Stitching already does the same sort of thing elsewhere: when two subgraphs contribute fields to one type, the tooling has to know which key fields to request from each side so it can match the halves back up, and it adds them to the selection set for exactly the same reason. Two consequences follow from this. First, a delegated field can cause the parent subgraph to be asked for more than the client requested, which is well worth knowing when you’re staring at query logs wondering who on earth asked for name. Second, a declaration ought to fail loudly at composition time if it references a parent field that doesn’t exist on the type, because that’s a schema error and the build is the right place to catch it.
What Happens on a List of Fifty Documents
And now for the consequence I promised. A detail page delegates once. A list page delegates per item:
query {
documents(collectionId: "7") { # 50 results
name
description { title } # delegated, per document
}
}
One call fetches the list, then fifty more calls fetch the descriptions. That’s the N+1 problem, and we got there by adding a single line to a schema extension. There are three mitigations worth knowing about, and they are definitely not interchangeable.
Selection already limits the damage. GraphQL only resolves the fields a client actually selects, so a list view that asks for name and revision triggers no delegation whatsoever. This is free, and it’s the reason delegated fields are tolerable in a schema at all. But it’s a property of the client’s query, which means one careless component can still turn a perfectly well-behaved schema into fifty requests.
A batch field on the target subgraph turns 1 + 50 into 1 + 1, assuming the target can expose one:
extend type DocumentList {
descriptions: [RichTextContent]
@delegate(
to: "content",
field: "documentContents",
args: { slugs: "f_documentSlugs" }
)
}
This is the best outcome and also the one you can least often have, since it needs the target API to accept array arguments and to return results in a shape you can map back onto the parents. And notice what changed in the schema, too: the field moved from the item to the list, so the client’s query shape changes right along with it. That’s a contract change, not a transparent optimization.
A DataLoader in the gateway leaves the schema alone and batches at execution time instead. The trick it exploits is how GraphQL execution actually works: all fifty description resolvers start in the same turn of Node.js’s event loop, because the list is already sitting in memory and nothing makes any of them wait for the others. So instead of firing off a request immediately, each resolver hands its key to a shared loader and gets a promise back. The loader collects up the keys that arrived during that turn, issues one batched query, and then resolves each promise with its own slice of the response:
There’s one detail that decides whether this ends up an optimization or an incident: the DataLoader has to be created per request, not per process. A DataLoader is a cache as well as a batcher, so a process-wide instance shares its results between requests. That means serving one visitor’s content to another if the key doesn’t include their scope, and serving yesterday’s content forever because nothing ever invalidates it. This is the same request-state discipline Part 2 talked about, showing up in the place where it’s easiest to get wrong, because a module-level loader honestly does look tidier than one threaded through the GraphQL context.
And even batched, the work hasn’t gone anywhere: the content subgraph now receives one large query instead of fifty small ones, which is usually better and isn’t always. So bound the list size in the schema, or a client can ask you for ten thousand documents and their descriptions in a single operation.
Authorization and Failure on a Delegated Field
A delegated field is a second data access with a trust question of its own, and the declaration format hides that question completely, so you have to answer it explicitly.
The gateway decides what travels along with the delegated call. Forward the visitor’s credentials and the target enforces its own rules. Use a service credential and the gateway is suddenly the only thing standing between a visitor and content they may have no business seeing. Both are legitimate choices, and the choice belongs in the declaration’s implementation rather than in some developer’s assumption. Part 15 deals with trust boundaries in general.
Failure behaviour follows Part 3’s partial-result discussion, with one addition that’s specific to lists. If the content subgraph fails for one item, then a nullable field gives you description: null for that item plus an errors entry, and the other forty-nine render perfectly well. That’s usually what you want, and it means the generated client types will hand your components a nullable value, so the component needs a sensible empty state rather than an assumption. Decide on the timeout, too: fifty delegated calls all waiting on a slow dependency gives you a page that eventually fails slowly, which is rather worse than a page that degrades quickly.
Testing What the Declaration Means
Delegation spans systems, so the interesting failures aren’t inside any one of them. Three levels cover different classes of mistake:
| Level | What it catches | How |
|---|---|---|
| Unit | Placeholder and formatter semantics | Call the formatters directly: slug, lower, upper, and the null and empty-string cases |
| Integration | Wiring and shape | Execute an operation against the composed schema with both subgraphs mocked; assert the merged result, the arguments the target received, and that required parent fields were requested |
| End-to-end | Real conventions | Query the running gateway and assert a real document resolves real content, which is where slug conventions that drifted between systems surface |
The middle row is the one that really pays for itself. Asserting what arguments the target subgraph received is what lets you tell “the description is null because there is no content” apart from “the description is null because we sent undefined”. That’s a distinction which is expensive to make from production logs and dirt cheap to make in a test.
What the Declaration Buys
Five lines in the schema extension layer replace a join that would otherwise get restated in every single component showing a document with its editorial text. And the relationship is now declared where the schema gets assembled, reviewed like schema, and covered by tests that can actually assert its semantics.
What those five lines don’t do is make the second lookup free, decide who’s allowed to see the result, or protect a list page from fan-out. Every one of those is a decision the syntax happily lets you leave implicit, and that’s the honest lesson of this article: a declarative shortcut concentrates knowledge in one place, and it hides the cost of the thing it declares.
So here’s the rule I’d apply to any delegated field before merging it. Name the parent fields it depends on, the number of calls it makes on the largest list that can select it, the credential it travels with, and what the client renders when it comes back null. If the answer to any of those four is “I’d have to go and read the gateway code”, then the declaration isn’t finished yet.
Further Reading
- delegateToSchema and schema extensions — the mechanism these examples build on
- Schema stitching docs —
stitchSchemas, subschema config, and type merging - Schema Stitching Handbook — runnable examples including batching and DataLoader patterns
- DataLoader — batching and per-request caching
- Apollo Server documentation
What’s Next
- Part 5: GraphQL-Based Code Generation — Turning the composed schema, including delegated fields, into client types the build checks.
- Part 6: Nuxt Module Boundaries — Giving the generated code and its consumers enforceable boundaries.
Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.
Category: Nuxt in Production