Duplicated Contracts — When a One-Field Change Touches Six Places
Photo by Şahin Sezer Dinçer on Pexels
Table of Contents
- A One-Field Change in a Dual-Rendering Application
- The Decisions That Get Duplicated
- Why the Build Does Not Catch a Mismatch
- Rendering Twice: The Hydration Gap
- Would a Single-Page Rewrite Remove the Tax?
- One Renderer, One Generated Contract
- The Revision Label, Rebuilt
- A Diagnostic for Synchronization Cost
- What’s Next
A One-Field Change in a Dual-Rendering Application
Throughout this series I’ll be using a fictional document application: users browse documents, open a document detail page, and compare revisions of the same document. The example is made up, but the kind of trouble it runs into is something I’ve seen in plenty of long-lived web applications.
So here’s the change request. On the document detail page, show the revision number next to the document title: “Quarterly Report — Revision 7”. One number. It’s already in the database and it’s already loaded by the service that renders the page. In a well-factored application you’d be done in ten minutes.
In the architecture I want to look at first, you won’t be. That architecture is a dual-rendering one: ASP.NET MVC renders the page’s HTML through Razor views, and afterwards Vue components get mounted into that HTML to make individual areas interactive. The document header is Razor. The revision switcher right next to it is Vue. They sit in the same markup and they get their data from two completely different places.
The JSON bootstrap blob in that diagram is the bit worth pausing on. The Vue components mount in the browser, so they have no access whatsoever to the C# objects the server just used. Which means the server has to serialize the same data a second time, into a data- attribute or an inline <script> tag, so that the client has something to initialize itself from. Two representations of one document, produced by one request, consumed by two renderers.
So our one little revision number has to be added in six places: the C# view model gains a Revision property, the Razor view prints it, the serialization code puts it into the blob, the TypeScript interface declares it, the Vue component reads it, and a test or two assert that the header and the switcher agree on which revision they’re showing. None of that is hard. But every single one of them is a decision that now exists twice.
The Decisions That Get Duplicated
Now you could argue that this is simply a case of “too many layers”. I don’t think layer count is the interesting number, though, because layers can be cheap. What actually costs you is the number of decisions that must agree across a boundary while nothing checks that they do.
For our revision label, those decisions are pretty concrete:
C# view model (server) TypeScript model (browser)
────────────────────── ──────────────────────────
DocumentDetailsModel.cs → documentDetails.ts
.Revision (int) .revision (number)
.Title (string) .title (string)
.ValidFrom (DateTime) .validFrom (string)
.IsCurrent (bool) .isCurrent (boolean)
.State (enum DocumentState) .state (string union)
Every arrow in that listing is an agreement that somebody has to maintain by hand: the field name after JSON serialization, whether a DateTime arrives as an ISO string or a locale-formatted one, whether State is serialized as a number or as a name, and what the client is supposed to do when a value simply isn’t there. The property pairs are at least visible in code review. The formatting and null-handling agreements usually aren’t; they live in a serializer configuration on one side and in an if statement on the other.
This is why I find “separation of concerns” a little misleading in a system like this. Structurally, C# and TypeScript really are separated: different languages, different build pipelines, in some cases different repositories. Functionally they’re joined at the hip, because you can’t change the Vue component without knowing exactly how the serializer on the other side behaves. Real separation would mean that a change on one side doesn’t force a change on the other. Duplicated data contracts fail that test, and you won’t see any of it in the folder structure.
Why the Build Does Not Catch a Mismatch
If something actually checked the duplication, it would be a nuisance rather than a risk. Usually nothing does, and the reason is structural: the wire format between the two sides is untyped JSON. The C# compiler verifies the server half. The TypeScript compiler verifies the browser half, against a model some developer wrote by hand to describe what it expects to receive. Neither compiler can see the boundary itself.
Rename Revision to RevisionNumber in C# and both builds succeed quite happily. What you get is undefined in the revision switcher, an empty label, and, if the component formats the value, a runtime error in the browser. If you’re lucky, the failure shows up in QA. If you’re not, it shows up in production. Either way it shows up a long way from the edit that caused it.
Plenty of teams close part of this gap by generating TypeScript clients from an OpenAPI description of their REST endpoints, and that genuinely works: a generated client is a checked contract, so a renamed field becomes a compile error once you regenerate. Two limits matter for the architecture I’m describing here. First, generation only helps if the description is derived from the current server code and regenerated in the build. Hand-maintain the specification and you’ve just moved the same synchronization problem up one level. Second, it only covers the REST surface. Our document application also reads marketing content from a headless CMS, feature flags from a cloud service, and page data from the Razor JSON blob, which has no specification at all. Those contracts stay handwritten.
Rendering Twice: The Hydration Gap
The duplicated contracts explain the bugs. The duplicated rendering explains why the page feels off even when nothing is actually broken.
Modern SSR frameworks like Nuxt or Next.js solve the server-renders-then-client-takes-over problem with hydration: the browser receives the markup the server rendered and the state the server used, and the client-side components attach their event handlers to the DOM that is already there instead of producing it all over again. Nothing visible happens at that moment, and that’s the entire point.
A dual-rendering application has nothing of the sort, because the two renderers never shared a component tree in the first place. The Razor output is displayed as soon as it arrives. Later, once the bundles have downloaded and executed, Vue mounts and replaces its sections of the DOM with markup it rendered itself.
Two consequences come out of the same cause. Visually, the replaced sections shove around the content near them: the switcher renders at a different height than the placeholder markup did, so everything below it jumps. That jumping is what Cumulative Layout Shift measures, a score that accumulates how much of the viewport shifted, weighted by how far it moved, for content the user could already see. Functionally, there is a window in which the page looks finished and doesn’t respond, because the revision switcher is still inert markup. On a slow device that window is long enough for a user to click and get nothing back, and on the document detail page the click they lose is the one the page exists for in the first place. That’s an illustrative failure mode of this pattern, by the way, not a measurement of any particular site. Part 13 deals with how to measure such effects, and Part 19 with how to compare the numbers honestly.
Would a Single-Page Rewrite Remove the Tax?
So the obvious response is to stop rendering twice: move all rendering into a single-page application and let the server expose data only, typically through a backend-for-frontend (BFF), which is a server-side API shaped for one frontend rather than for general reuse.
And that does fix the rendering half. One renderer, one component tree, no bootstrap blob, no replaced sections, no hydration gap, and the layout stops jumping. If the visual behaviour described above is your problem, then this is a real answer.
What it doesn’t touch is the contract half. Push our revision label through a SPA plus BFF and the work is: add the field to the C# DTO, mirror it in the handwritten TypeScript model, extend the client method that calls the endpoint, and read it in the component. The Razor view and the blob are gone, granted, but the agreements about field names, date formats, enum encoding and null handling are just as numerous and just as unchecked as they were before. On top of that, every endpoint brings its own plumbing along: headers, tokens, error mapping, pagination, retries, caching, all written once per endpoint because nothing generates any of it.
That’s the observation the rest of this series is built on. Replacing the frontend framework deals with duplicated rendering. It does nothing about manual synchronization, and manual synchronization is what makes a one-field change expensive.
One Renderer, One Generated Contract
So the architecture I’m aiming at has to get rid of both duplications at once. Two decisions do most of that work.
Rendering has one owner. Nuxt renders the Vue component tree on the server, and the very same tree hydrates in the browser. There is no second template language and no second data representation to keep aligned, because the framework serializes the state the server used and the client picks it up from there. Don’t mistake this for a free win, though, because it’s a real constraint: the server and the browser have to start from the same state, or hydration hands you mismatches instead of silent attachment. Part 12 is about exactly that failure class.
Client contracts are derived, not written. A GraphQL gateway presents the document application’s data as one schema, composed from the .NET API and the CMS behind it. A schema is machine-readable, so the TypeScript types for a query can be generated from it during the build, and the query itself can be validated against it. The backend stays .NET. What changes is that the browser’s view of the data is derived from a definition instead of transcribed from one.
I’ve split that diagram into three phases on purpose, because they fail in quite different ways. Generation and validation happen at build time and give you compile errors. Composition and data fetching happen per request on the server and give you runtime errors and latency. Hydration happens in the browser and gives you mismatches. Knowing which of the three you’re currently debugging is most of the reason to draw this at all.
The Revision Label, Rebuilt
So, back to our change request: show the revision number next to the document title.
In this architecture that’s two edits. The document page’s query asks for revision alongside title, and the component renders it. Regeneration then updates the query’s TypeScript type, and the component either type-checks against it or doesn’t compile.
What’s worth noticing is which of the earlier failures turned into a build error. If revision doesn’t exist on the type the query selects from, validation fails against the schema. If the field is nullable in the schema and the component treats it as a number, type checking fails at the usage. The field name can’t drift, because nobody transcribed it anywhere. The date format question doesn’t come up for this particular field, and where it does come up, the schema answers it once: a GraphQL schema declares each leaf value’s type, a scalar such as Int, String or a custom DateTime, and that declaration defines how the value is serialized for every consumer. No more C# serializer and TypeScript parser deciding the format independently of one another.
And no, not all of the work disappears. The field still has to exist on the .NET side and be exposed through the schema, which is a backend change with its own review. The checks only tell you the truth if generation runs against the current schema in CI, so a stale generated file is a brand new thing to get wrong; Part 5 goes through those failure modes. The gateway now does the composition the page used to do itself, which moves cost around rather than removing it, and Parts 3 and 4 cover what that costs and how to bound it. Nullability, authorization and server-side validation remain the backend’s job, because a generated type describes a shape and enforces precisely nothing at run time.
So the honest summary isn’t that the change became free. It’s that the change became local, and that the agreements which remain are enforced by a build rather than by somebody’s memory.
A Diagnostic for Synchronization Cost
None of this means that an application built on either legacy pattern needs a rewrite. Plenty of Razor-plus-Vue systems are perfectly fine, and a migration of this size is only justified by a change volume you can actually point at. So before you conclude anything about your own system, take one small change, a single field from storage to screen, and count two things:
- How many places must agree for that field to display correctly: names, formats, enum encodings, null handling, and any rendering decision made more than once.
- How many of those agreements are verified automatically by a compiler, a generator, or a test that fails in the build.
It’s the ratio that tells you something, not the total. Ten agreements that all break the build are maintainable. Four agreements that break nothing are a source of production bugs, and they only get worse as the number of data sources grows, because every new integration brings its own unchecked set along with it. If your second number is close to zero and you make changes like this every week, then manual synchronization has stopped being overhead and has quietly become your architecture. And that’s the point at which it’s worth asking who should own rendering and who should own contracts.
What’s Next
This article was about the problem. The next ones work out the structure that answers it.
- Part 2: The Target Architecture — What happens on one document-page request, and which component owns which decision.
- 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.
Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.
Hero image credit: Photo by Şahin Sezer Dinçer on Pexels
Category: Nuxt in Production