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 use a fictional document application: users browse documents, open a document detail page, and compare revisions of the same document. The example is invented, but the shape of the problem is one that shows up in a lot of long-lived web applications.
Here is the change request. On the document detail page, show the revision number next to the document title: “Quarterly Report — Revision 7”. One number, already stored in the database, already loaded by the service that renders the page. In a well-factored application this is a ten-minute task.
In the architecture I want to describe first, it is not. That architecture is a dual-rendering one: ASP.NET MVC renders the page’s HTML through Razor views, and Vue components are mounted into that HTML afterwards to make individual areas interactive. The document header is Razor. The revision switcher next to it is Vue. They sit next to each other in the same markup and get their data from two different places.
The JSON bootstrap blob in that diagram is the part worth pausing on. Because Vue components mount in the browser and have no access to the C# objects the server used, the server serializes the same data a second time — into a data- attribute or an inline <script> tag — so the client has something to initialize from. Two representations of one document, produced by one request, consumed by two renderers.
So the 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 includes it in 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 about which revision is being displayed. None of that is difficult. All of it is a decision that now exists twice.
The Decisions That Get Duplicated
It is tempting to describe this as “too many layers.” Layer count is not the interesting number, because layers can be cheap. What costs you is the number of decisions that must agree across a boundary and are not checked by anything.
For the revision label, those decisions are 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 someone 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 a name, and what the client should do when a value is absent. The property pairs are visible in code review. The formatting and null-handling agreements usually are not; they live in a serializer configuration on one side and an if statement on the other.
This is why “separation of concerns” can be misleading in such a system. Structurally, C# and TypeScript are separated — different languages, different build pipelines, different repositories in some cases. Functionally they are coupled, because the Vue component cannot be changed without knowing exactly how the serializer on the other side behaves. Real separation means a change on one side does not force a change on the other. Duplicated data contracts fail that test, and the duplication is invisible in the folder structure.
Why the Build Does Not Catch a Mismatch
If the duplication were checked, it would be a nuisance rather than a risk. It is usually not checked, 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 a 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 still succeed. 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. The failure surfaces in QA if you are lucky and in production if you are not, and it surfaces far from the edit that caused it.
Many teams close part of this gap by generating TypeScript clients from an OpenAPI description of their REST endpoints. That works: a generated client is a checked contract, and a renamed field then becomes a compile error after regeneration. Two limits matter for the architecture in this article. First, generation only helps if the description is derived from the current server code and regenerated in the build — a hand-maintained specification reintroduces the same synchronization problem one level up. Second, it only covers the REST surface. The 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 contract duplication explains the bugs. Duplicated rendering explains why the page feels wrong even when nothing is broken.
Modern SSR frameworks such as 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 event handlers to the existing DOM instead of producing it again. Nothing visible changes at that moment, which is the entire point.
A dual-rendering application has no such mechanism, because the two renderers never shared a component tree to begin with. Razor output is displayed as soon as it arrives. Later, when the bundles have downloaded and executed, Vue mounts and replaces its sections of the DOM with markup it rendered itself.
Two consequences follow from the same cause. Visually, replaced sections move the content around 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 does not 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, and on the document detail page the click they lose is the one the page exists for. That is an illustrative failure mode of this pattern, 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?
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) — a server-side API shaped for one frontend rather than for general reuse.
That does fix the rendering half. There is one renderer, one component tree, no bootstrap blob, no replaced sections, no hydration gap, and the layout no longer jumps. If your problem is the visual behaviour described above, this is a real answer.
It does not touch the contract half. Take the 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; the agreements about field names, date formats, enum encoding and null handling are exactly as numerous and exactly as unchecked. On top of that, every endpoint carries its own plumbing — headers, tokens, error mapping, pagination, retries, caching — written once per endpoint because nothing generates it.
This is the observation the rest of the series is built on. Replacing the frontend framework addresses rendering duplication. It does nothing about manual synchronization, and manual synchronization is what makes a one-field change expensive.
One Renderer, One Generated Contract
So the target architecture has to remove 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 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 reuses it. This is a real constraint rather than a free win: the server and the browser must start from the same state, or hydration produces 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. Because a schema is machine-readable, the TypeScript types for a query can be generated from it in 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 rather than transcribed from one.
The diagram separates three phases deliberately, because they fail differently. Generation and validation happen at build time and produce compile errors. Composition and data fetching happen per request on the server and produce runtime errors and latency. Hydration happens in the browser and produces mismatches. Knowing which phase you are debugging is most of the value of drawing this at all.
The Revision Label, Rebuilt
Back to the change request: show the revision number next to the document title.
In this architecture there are two edits. The document page’s query asks for revision in addition to title, and the component renders it. Regeneration then updates the query’s TypeScript type, and the component either type-checks against it or does not compile.
What is worth noticing is which of the earlier failures became a build error. If revision does not 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 cannot drift, because nobody transcribed it. The date format question does not arise for this field, and where it does arise 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, instead of the format being decided independently by a C# serializer and a TypeScript parser.
And some work does not disappear. 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 the truth if generation runs against the current schema in CI, so a stale generated file is a new thing to get wrong — Part 5 goes through those failure modes. The gateway now performs the composition the page used to do itself, which moves cost rather than removing it; 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 nothing at runtime.
The honest summary is not that the change became free. It is that the change became local, and that the remaining agreements are enforced by a build instead of by memory.
A Diagnostic for Synchronization Cost
None of this means an application on either legacy pattern needs a rewrite. Plenty of Razor-plus-Vue systems are fine, and a migration of this size is only justified by change volume you can actually point at. So before concluding anything about your own system, take a single small change — one 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.
The ratio, not the total, is the signal. Ten agreements that all break the build are maintainable. Four agreements that break nothing are a source of production bugs, and they get worse as the number of data sources grows, because each new integration adds its own unchecked set. If the second number is close to zero and you make changes like this every week, manual synchronization has stopped being overhead and become the architecture — which is the point at which it is worth asking who should own rendering and who should own contracts.
What’s Next
This article argued for 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