GraphQL Schema Stitching — Composing One Schema and Owning the Consequences
Photo by Rafael Minguet Delgado on Pexels
Part 2 handed the gateway a job description, namely that it owns composition and integration policy, without ever showing how it does either. So this article shows the mechanism, and then spends most of its length on the three things composition turns into your problem: cache identity, invalidation across processes, and what a page returns when one of the sources is down.
The examples use the same fictional document application as the previous parts. They’re there to demonstrate the pattern, not to show any particular system’s schema.
Table of Contents
- The Join the Frontend Should Not Perform
- Composing Two Schemas Into One
- What One Operation Does Not Promise
- Adding a Field That Crosses Sources
- Which Results May Be Cached and Under What Identity
- Invalidating a Cached Result in Every Worker
- When a Subgraph Fails
- What the Gateway Now Owns
- Further Reading
- What’s Next
The Join the Frontend Should Not Perform
Our document detail page needs two things from two systems. The record itself, so name, revision and validity, comes from the .NET API. The explanatory text shown beneath the title is editorial content, maintained in the CMS and keyed by a slug derived from the document’s name.
Without composition, the page component gets to do the join itself. It calls the API, waits for the record, derives the slug from the name, calls the CMS, and merges the two results. Which means the component now knows the CMS’s authentication scheme, its error shape, its slug convention, and the order in which the two calls have to happen. Multiply that by every page that shows editorial content next to application data and you’ve got transport knowledge smeared all over the frontend. That’s a variant of exactly the duplication Part 1 described, except the duplicated decision is now “how do I talk to system X” rather than “what is this field called”.
Composing the two schemas moves that join behind one contract. The component asks for a document with its description, and something else decides where each half comes from.
Composing Two Schemas Into One
Both sources already expose GraphQL schemas, which gives the gateway something to build on. Composition then happens in three steps.
First, it needs each schema’s definition, meaning the types, fields and arguments the source supports. That can come from a schema file checked into the repository, or from introspection: a GraphQL server can answer a special query describing its own schema, so the gateway just asks the source at startup what it has to offer. Introspection is convenient, but it creates a dependency on the source being reachable when the gateway starts. A file is stable, but it can be out of date. Both choices are common, and Part 5 comes back to the staleness question.
Second, each schema gets paired with an executor, a function that takes an operation and actually runs it against that source. For a remote subgraph that means sending an HTTP request with the right URL, headers and credentials. The schema says what can be asked; the executor knows how to ask it.
Third, stitchSchemas from @graphql-tools/stitch merges those schema-plus-executor pairs into one schema whose fields remember which executor resolves them. That composed schema is then served by Apollo Server running inside the Nuxt server process.
The two entry points on the left are the ones from Part 2: during server-side rendering the operation executes against the local schema with no loopback HTTP request, and after hydration the browser sends the same operation over HTTP. Both of them arrive at the same composed schema, and that’s the point. One contract, two transports.
A word on the alternative, because the choice here matters rather more than the tooling does. Apollo Federation composes schemas too, but it expects each subgraph to take part in the composition. Concretely, a subgraph marks the types it owns as entities, declares which fields identify them, and exposes a resolver that can return an entity given that identifier. A query planner in the gateway then uses those annotations to split an incoming query into a plan: fetch the document from subgraph A, ask subgraph B for its part of the same entity by key, merge. You get better planning, and you pay for it by having to change every subgraph. Stitching makes no such demand, and that’s exactly what recommends it when one of your sources is a product you don’t control, such as our CMS here, whose schema you can’t decorate. If you do own every subgraph, then federation’s query planning and tooling are worth evaluating seriously. Stitching is the right answer to “these schemas exist and they are not going to change for me”, not a generally superior model.
What One Operation Does Not Promise
With the composition in place, our page gets to ask one question:
query DocumentPage($path: String!, $id: ID!) {
page(path: $path) { # resolved from the content subgraph
title
description
}
document(id: $id) { # resolved from the .NET API subgraph
id
name
revision
}
}
The client doesn’t join two HTTP responses any more, and code generation can derive this operation’s result type from the composed schema, which Part 5 covers. But there are two limits worth stating before somebody starts quoting request counts in a design review.
First, one operation is not one downstream request. The gateway still calls both subgraphs. All it has really done is move the fan-out to the server, where it’s at least visible in one place and traceable in one span tree.
Second, one page isn’t necessarily one operation either. Child components can declare their own data needs, pagination fetches more, and a delegated field adds a call per parent object. So if somebody claims the migration reduced the number of backend calls, ask them which operation and which page they actually measured.
Adding a Field That Crosses Sources
So far, the composed schema is really just two schemas sharing an endpoint. What our page actually wanted was the document’s description: a field on the document type, not a separate root query that the component then has to correlate by slug.
We can declare that relationship in the schema extension layer:
extend type DocumentModel {
description: RichTextContent
@delegate(
to: "content",
field: "documentContent",
args: { slug: "{name:slug}" }
)
}
Now, @delegate here is not a GraphQL built-in. It’s a directive the gateway implements itself: the gateway declares it, reads name off the already-resolved parent document, applies a slug transformation, and calls documentContent on the content subgraph via delegateToSchema. Part 4 takes that implementation apart, including how placeholders keep their types and how the field behaves when the parent value simply isn’t there.
And here’s the consequence I want to flag right at the point of the decision rather than later on: this field costs you one extra call per parent object. On a detail page that’s one call. On a list of fifty documents where the client selects description, it’s fifty. That’s the N+1 problem, and you got there by declaring a convenience. Mitigations do exist (a batch field on the target subgraph, a request-scoped DataLoader, and the fact that GraphQL only resolves fields the client actually selects), and they make up the second half of Part 4. What matters here is that adding a cross-source field is a performance decision dressed up as a schema decision.
Which Results May Be Cached and Under What Identity
Caching at the gateway is tempting, because the gateway is where all the expensive work happens. It’s also where a caching mistake has the widest blast radius. So it needs two separate decisions, and conflating those two is the classic bug.
The first decision is eligibility: may this operation’s result be reused at all? Editorial content that every visitor sees identically usually can, under a bounded time to live. A TTL is the maximum age at which an entry may still be served before it gets discarded and fetched again, and it’s the simplest way to put an upper bound on how stale an answer can be. A document list filtered by a visitor’s permissions usually cannot, or only within that visitor’s scope. An operation allowlist is a decent way to express eligibility, because it makes “cacheable” an explicit, reviewable property of a named operation instead of a heuristic.
The second decision is the cache key, and no, the operation name is not it. The key has to include everything that varies the result:
cache identity includes, where relevant:
operation / document identity
normalized variables (document id, pagination, filters)
locale and preview mode
authorization scope (role, tenant, or user, if data depends on it)
content or schema version (so a deploy or content change cannot serve stale shapes)
Leave out the normalized variables and document 42’s page gets served for document 43. Leave out the authorization scope and one visitor’s data gets served to another. That second failure is the most damaging thing in this entire architecture, and it sits exactly one forgotten key component away from you.
There are two further properties of the cache that are easy to discover the hard way. An in-memory cache is per process, so if the application runs several Node.js workers then each one has its own copy, and a hit rate measured on one worker is not the system’s hit rate. Also, the cache is a dependency and dependencies can fail, so decide deliberately whether a cache outage means falling through to the subgraphs. That preserves availability while multiplying their load at exactly the moment something is already going wrong. And don’t cache errors indefinitely, or a five-second blip turns into an hour of failures served straight from memory.
There’s one more failure mode I want to describe in full, because it’s a design accident rather than a typo. If the permission check happens inside the resolver, then it only runs on a cache miss. A cache hit returns the stored result directly and never gets anywhere near the code that would have rejected the request. In normal operation this stays nicely hidden, because whoever caused the miss was authorized. It surfaces when a differently authorized visitor hits the same key, or when somebody adds a caching layer above a resolver that was quietly carrying the check. So keep authorization on the path that every request takes, or make it part of the cache identity, so that a visitor who may not see a result can’t match its key in the first place.
Invalidating a Cached Result in Every Worker
TTLs answer “how stale may this get”, but they don’t answer “an editor just fixed a typo and would quite like to see it”. For that, the CMS sends a webhook when content changes. The receiving worker invalidates its own entries and publishes an invalidation message (Redis Pub/Sub is one way to do it) so that the other workers drop theirs as well.
This coordinates invalidation. What it doesn’t do is make it atomic, and that distinction has real operational consequences. Pub/Sub delivers to the subscribers that are connected right now, so a worker that happened to be restarting when the message went out never learns about it and cheerfully keeps serving its stale entry until the TTL expires. Which means the mechanism you pick should follow from the freshness you actually need. A short TTL plus best-effort invalidation is perfectly fine for editorial text. Content that must never appear stale wants versioned keys instead, where a version change makes the old keys unreachable rather than requiring anybody to delete them, or a durable, replayable invalidation channel.
There are three details that make the difference between this working and this being a liability.
Verify the webhook’s signature. The CMS signs the request body with a shared secret, and your endpoint recomputes that signature before it acts on anything. Without that, the endpoint is a public button that empties your cache: anyone who finds the URL can call it in a loop, every call sends the gateway back to the subgraphs for absolutely everything, and the .NET API and CMS take the full unmitigated load. That’s a denial-of-service attack which needs no volume of traffic at all, just knowledge of one path.
Make the processing idempotent, meaning that handling the same message twice has the same effect as handling it once, because webhook senders retry when they don’t get a timely acknowledgement. Invalidation is naturally idempotent, since dropping an already-dropped entry is harmless, but anything you bolt onto it, like warming the cache again or writing an audit record, isn’t automatically so.
And keep preview content out of the delivery cache entirely. Part 10 deals with preview.
When a Subgraph Fails
Composition raises a question the frontend never had to answer back when it made its own calls: if the CMS is unavailable but the .NET API is perfectly happy, what does the document page get?
GraphQL’s answer is that this is normal rather than exceptional. A response can carry data with description: null alongside an errors entry explaining why, provided the field is nullable. And that’s genuinely useful, because the page can render the document and just leave out the editorial text. But it only works if two things are true. The schema has to make the field nullable, which is a design decision you take when the field is added, not when the outage happens. And the client has to handle a partial result, which means the generated types will hand your component a nullable value and the component has to do something sensible with it instead of assuming it’s there.
So the gateway needs an explicit policy per relationship: which subgraph failures degrade a page and which ones fail it, what the timeout is, whether a retry is safe for that particular operation, and whether an error’s message is safe to forward to a browser. None of those are new requirements, mind you. The legacy application had them too. The difference is that they now have one place to live, which is the argument for having a gateway at all and also the reason it needs an owner.
What the Gateway Now Owns
The payoff for the frontend is narrow but real: components ask for documents and descriptions through one contract, with no transport knowledge and no hand-written join anywhere. The cost is that a whole list of policies which used to sit implicitly inside scattered fetch calls is now explicit in one component. Authentication and field-level authorization, operation complexity and list limits, timeouts and retries, degradation behaviour, cache eligibility and cache identity, invalidation, and validating schema changes against the operations already in use.
So this isn’t a reduction in complexity. It’s a relocation of complexity into somewhere it can be reviewed and tested. Which gives me the test I’d apply before adding anything to a gateway: for each policy above, can you name the file that implements it and the test that would fail if it were wrong? If a policy has no such place, then it isn’t centralized at all. It’s just somewhere else.
Further Reading
- Apollo Server documentation
- GraphQL Tools schema stitching
- Schema extensions and delegation
- Schema Stitching Handbook
What’s Next
- Part 4: The Custom Delegate Directive — How the delegated field above is implemented, and how to keep it from turning a list into fifty requests.
- Part 5: GraphQL-Based Code Generation — Turning the composed schema into client types the build can check.
Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.
Category: Nuxt in Production