The Target Architecture — Who Owns Which Decision on One Request
Photo by REFARGOTOHP on Pexels
Part 1 left us with a question rather than a plan: in an application where a one-field change touches six places, who should own rendering and who should own the data contract? So in this article I’ll answer that by walking through a single request to a single page, naming the component that decides each thing along the way.
The application is the same fictional one as in Part 1: users browse documents and open a document detail page. Note that the diagrams show logical responsibilities, not a complete service inventory.
Table of Contents
- The Decisions Behind One Document Page
- The Components Involved in One Request
- Server-Side Rendering and Data Fetching
- The GraphQL Gateway Inside the Nuxt Server
- Browser Hydration and Later Interactions
- What Is Decided at Build Time
- What Changes Between Environments
- The Responsibility Map and the Coupling That Remains
- What’s Next
The Decisions Behind One Document Page
So let’s take the URL /documents/42. To produce that page, something has to decide:
- which document the path refers to, and whether the visitor may see it;
- what the document’s data looks like: title, revision, validity dates, state;
- what editorial content belongs on the page, because the introductory text above the document is maintained in a CMS and not by us developers;
- what the markup is, both for the first paint and for every later re-render;
- which of these results may be cached, and for whom;
- what happens if one of the sources is slow or unavailable.
Not one of those decisions is new. The legacy application in Part 1 made every single one of them too. Its problem was that several of them were made twice, once in C# and once in TypeScript, with nothing checking that the two answers agreed.
And that’s the risk worth naming before I start drawing boxes. Swapping Razor for Vue and REST for GraphQL doesn’t by itself stop a decision from being made in two places; it only changes where those two places are. So the test I’ll apply to each item below isn’t “is this modern”, it’s: which single component owns this decision, and if two components have to agree about it, is that agreement checked by something other than memory?
The Components Involved in One Request
Four things take part in the request, plus a cache.
The reverse proxy sits in front of the application and owns nothing at all about the page itself. It routes, sets response cache headers, and starts the trace that later ties a slow page back to a slow dependency. One thing I want to state explicitly, because it confuses people surprisingly often: sending a cache header is not the same as holding a response in a local cache. The header tells clients and any shared cache what they’re allowed to reuse. Whether anything actually does is a separate decision.
The Nuxt server is a Node.js process that renders the Vue component tree and serves the client bundles. Everything to do with markup lives here, once, for both the server render and the browser.
The GraphQL gateway runs inside that same Node.js process. It exposes one schema composed from several subgraphs, in this example the .NET API’s schema and the content service’s schema, so that a page can ask one question instead of coordinating two HTTP clients. Part 3 covers how that composition is built. Here it only matters as an owner of decisions.
The .NET API keeps the application behaviour: business rules, persistence, integrations with other services. Keeping it there is a deliberate choice rather than a limitation of Node.js. The problem Part 1 described is duplicated contracts and duplicated rendering, and porting working business logic to another language would solve neither of those while stacking a second rewrite on top of the first.
The shared cache is there because both the gateway and the application benefit from reusing results across requests and across processes. I’m listing it as a component rather than an implementation detail because caching brings a correctness question of its own along with it, which shows up a bit further down.
Server-Side Rendering and Data Fetching
The request arrives at the Nuxt server, which resolves /documents/42 to a page component and renders the tree to HTML.
Rendering on the server has one consequence that dominates the whole rest of this series: the code doing the rendering also needs the data, and it needs it before the HTML can be produced. In a single-page application a component can happily mount empty and fill itself in later. During SSR it can’t. Whatever is missing is missing from the HTML, full stop.
So the page component declares what it needs and awaits it:
// Runs on the server during SSR, and in the browser on later navigations
const { data } = await useAsyncData('document-42', () => fetchDocumentPage(route.params.id))
There are two properties of that call which are easy to overlook and expensive to get wrong.
The first is the cache key. useAsyncData stores its result under the key you give it, and that store is per request rather than per process. The shared cache behind the gateway, however, is not. Any result cached across requests has to be keyed by everything that varies its content: the document id, the locale, and the visitor’s permissions if the data depends on them. A cache key that ignores identity is how one user’s data ends up in front of another user. If the key is only document-42, then the first visitor’s response, including any field they were allowed to see and the next visitor isn’t, is what everybody else gets served until the entry expires. I’d call this the single most dangerous mistake available in this architecture, precisely because nothing about it looks like a bug when you’re testing with one account.
The second is that the request’s state, so locale, authentication and feature assignments, has to be established before rendering starts, and it must not be read from anything process-global. A module-level variable holding “the current user” works beautifully in development with one visitor and leaks between concurrent requests in production. Part 6 comes back to this when it gets into what a module may own.
The GraphQL Gateway Inside the Nuxt Server
Our page needs the document record from the .NET API and the introductory content from the CMS. So it asks for both of them in one operation:
query DocumentPage($path: String!, $id: ID!) {
# resolved from the content subgraph
page(path: $path) {
title
description
}
# resolved from the .NET API subgraph
document(id: $id) {
id
name
revision
}
}
The interesting part is what happens when that operation gets executed during SSR. The gateway lives in the same process as the renderer, so you can configure the client to execute against the local schema directly instead of sending an HTTP request to the application’s own endpoint:
Vue component
→ generated composable / useAsyncData
→ server-side Apollo client
→ in-process GraphQL execution (no loopback HTTP)
→ subgraph resolvers
→ .NET API (network)
→ content service (network)
→ shared cache (network)
That saves you a serialization round trip and a loopback connection per operation. What it doesn’t do is make the dependencies local. The resolvers still cross the network, and the page is still only as fast as its slowest subgraph. Which is why “one operation” and “one downstream request” are two different counts, and why Part 4 exists at all: a list of documents that each resolve a related field can quietly fan out into what’s usually called N+1 calls. One request to fetch the twenty documents, plus one more per document to fetch its related field, so twenty-one downstream calls hiding behind a query that looks like a single one.
So what the gateway owns is composition and integration policy. It decides how a field maps to a subgraph, which credentials get forwarded, what a timeout does, and whether a failed subgraph produces a partial result or an error. Those decisions used to be scattered across every component that called an endpoint, and concentrating them is the whole point. The price is that the gateway is now a component with a lifecycle, a schema to keep current, and failure modes of its very own.
Browser Hydration and Later Interactions
The server sends HTML plus the state it used to produce it. In the browser, the same components attach themselves to the existing DOM rather than rendering it all over again (this is the hydration I described in Part 1), and the data from the payload gets reused instead of refetched.
After hydration the transport changes, but the contract doesn’t. When the user switches to another revision, the browser sends the same kind of GraphQL operation over HTTP to the gateway’s endpoint, and the gateway resolves it the same way it did before. One schema, two transports, and no second data contract to keep in sync, which is exactly the duplication Part 1 was complaining about.
There’s a condition attached, though. Hydration only works if the browser starts from the state the server used. Any decision made during SSR that the client could plausibly recompute for itself, so the active locale, the current time, an authorization outcome, or which variant of an experiment the visitor is in, has to be transferred rather than re-derived. Otherwise the client renders something different from the HTML it’s attaching itself to. Part 11 covers that for experiment assignment, and Part 12 for hydration mismatches in general.
What Is Decided at Build Time
Several of the agreements above get settled before the server even starts, and that’s the whole reason the revision label in Part 1 turned into a two-line change.
| Source of truth | Generated from it | What still has to be checked |
|---|---|---|
| Composed GraphQL schema and operations | Result and variable types, typed document nodes | That operations validate against the current schema, and that consumers type-check |
| Translation definitions | Typed key access with parameter signatures | Locale coverage and formatting behaviour |
| Content model | Query scaffolding and component mappings | Rendering, and behaviour for content types nothing handles |
The pattern is the same in all three rows: something already describes the contract in machine-readable form, so the client’s view of it gets derived instead of transcribed. Rename revision in the schema and the build fails at the query. Make it nullable and the build fails at the component that treats it as a number.
And the caveat belongs right here rather than in some warning section at the end: all of this only holds if generation runs against the schema the running system actually exposes, and if the validation step runs in CI. A generated file that was committed months ago and never regenerated since is exactly as trustworthy as the handwritten TypeScript model it replaced. Part 5 goes through those failure modes properly. Also, a generated type describes a shape and enforces nothing at run time, so validation and authorization stay the .NET API’s responsibility.
What Changes Between Environments
There’s one question the walkthrough raises that I should answer. The gateway needs a URL for the .NET API, credentials for the content service, and an address for the cache. All of those differ between a preview environment, a shared integration environment, and production. So where do they live?
Not in the code, and definitely not in conditional branches. The same build artifact runs everywhere, and each environment’s settings get resolved by applying that environment’s overlay on top of a base configuration and then resolving references, where a setting can point at a secret held by the platform instead of containing it. Configuration is therefore just data that a release step resolves, and a wrong reference is something a release check can catch before any traffic shows up.
That’s as much as this article needs. Part 16 covers how the resolved configuration becomes a deployment, how a new version is verified before it gets traffic, and which compatibility problems a rollback won’t solve for you.
The Responsibility Map and the Coupling That Remains
Let’s collect the whole walkthrough into one table:
| Decision | Owner | Where it changes |
|---|---|---|
| Markup and interaction behaviour | Nuxt component tree | One component, used by both server render and browser |
| Which data a page needs | The page’s operation | One GraphQL document |
| How a field maps to a source | Gateway schema composition | Gateway configuration and resolvers |
| Business rules, validation, authorization | .NET API | Backend code and tests |
| Editorial content | CMS, via the content subgraph | Content model and editor input |
| Client data types | Generation from the schema | Regenerated in the build; never edited |
| Environment differences | Configuration overlay | Environment settings and secret references |
Now compare that to Part 1’s list of six edits for one field. The reduction isn’t down to having fewer components, because there are actually more of them. It’s down to each decision having exactly one owner, with the boundaries between owners described by machine-readable definitions.
What all this costs is worth stating plainly, too. The gateway is a component that somebody has to run, keep in sync with its subgraphs, and diagnose when one of those subgraphs misbehaves. Generation is a toolchain with its own upgrades and its own ways of going silently stale. SSR imposes a discipline on request state that’s easy to violate and thoroughly unpleasant to debug. And cache correctness is now an architectural concern rather than a performance trick. So no, none of this is free. You’re trading unchecked coupling for machinery you own.
Which gives me the rule I’d take away from this article. For each decision a page makes, write down the component that owns it and the file you would edit to change it. If a decision has two owners, ask yourself what checks that they agree: a compiler, a generated artifact, a test that fails the build. And if the answer is “a developer remembers”, then you’ve found the same problem Part 1 described, just in a newer stack.
What’s Next
The gateway carried an awful lot of weight in this walkthrough without ever really being explained. The next articles open it up.
- Part 3: GraphQL Schema Stitching — How one schema is composed from several sources, and what that costs in caching and failure handling.
- Part 4: The Custom Delegate Directive — Declaring field relationships across sources without re-creating N+1 work.
- Part 5: GraphQL-Based Code Generation — What the build actually checks, and how generated contracts go stale.
Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.
Category: Nuxt in Production