Modernize the Module Without Accidentally Rewriting Its Clients
A real HTTP contract distinguishes a compatible module rewrite from a plausible200response that changes nulls, rounding, status and dates.
Explore the mechanism ↓
Reading tools Listen or translate
Read aloud
Uses an installed voice on your device.
Translate article
Machine translation. Code and evidence notes stay in English.
Original English text.
START WITH THE MECHANISM
Interactive companion and laboratory update: 12 November 2026. The editorial date places the topic on the timeline; this current explanation and its measured evidence retain their actual preparation and execution dates.
THE FULL INVESTIGATION
Modernize the Module Without Accidentally Rewriting Its Clients
Dates and technology context
This is a retrospective editorial timeline. Topic context: 16 April 2026. First publicly available: 12 November 2026. Current text, visuals and laboratory revisions were prepared on 12 November 2026.
The actual ASP.NET11 routes, JSON results, feature-switch and rollback checks are a 2026-11-12 update. Treat all screenshots and response matrices as current fixture evidence.
A module can look cleaner after a rewrite while becoming less compatible with every client that depends on it. The new domain type has a better name, the enum is more explicit, null properties disappear from the JSON and the date handling seems simpler. Each change can be locally reasonable. Together they can create a different API under the old route.
The modernization decision is therefore larger than choosing between old and new code. It concerns which responsibilities remain stable, where compatibility is enforced and how the team knows that a rollback still works. A module's internal design can change without requiring its external contract to change at the same moment.
This article compares three strategies through a real ASP.NET Core HTTP fixture: a patched legacy-shaped implementation, a rewritten domain model behind a compatibility adapter, and a naive rewrite exposed as though it were the old API. The first two preserve independently specified golden responses. The third returns HTTP 200 but breaks the accepted client contract in several observable ways.
The exercise was executed on 12 November 2026 using SDK 11.0.100 and .NET/ASP.NET Core 11.0.0. It is a small stateless API study, not a claim about a real customer migration, database schema rollout or developer productivity. Its value is the concrete boundary it tests: requests and responses traveling through actual HTTP routing and JSON serialization.
Begin with what the client is entitled to rely on
The fixture accepts a receipt request containing accountId, amount, state, requestedAt and note. A version-one client sends the X-Api-Version header with value one. The response contains accountId, amount, status, requestedDate, note and version.
These names are part of the contract. So are their types and meanings. The response amount is rounded to two decimal places using midpoint rounding away from zero. The pending input state becomes the string queued. requestedDate represents the UTC date of the supplied offset timestamp. A null note remains an explicit null property. The version field remains the string one.
The first golden input deliberately crosses several boundaries at once:
{
"accountId": "A-01",
"amount": 12.345,
"state": "pending",
"requestedAt": "2026-11-12T01:30:00+02:00",
"note": null
}
Its independently written expected response is:
{
"accountId": "A-01",
"amount": 12.35,
"status": "queued",
"requestedDate": "2026-11-11",
"note": null,
"version": "1"
}
The date is the preceding UTC day, not the calendar date displayed in the input's offset. The amount is 12.35, not 12.34. The note property exists even though its value is null. A client that relies on these facts is not wrong merely because a new internal model would prefer another representation.
The test does not demand identical whitespace or JSON property order. It parses both responses, compares the property-name sets, requires matching JSON kinds and compares numeric values as decimals. That focuses the golden contract on the intended semantics while still detecting missing fields and type changes.
Patch the old shape when the boundary is already understood
The patched implementation constructs the legacy response directly. It keeps the wire vocabulary close to the calculation: account identity, rounded amount, mapped status, UTC date, note and version. This is the smallest strategy in the fixture because it does not introduce a new domain representation.
The approach has a legitimate role. If the module's responsibilities are narrow and the required repair is well understood, a coherent patch can preserve the contract with less migration machinery. Rewriting every component because some code is old can create risk without addressing a concrete unmet requirement.
The fixture does not include an inherited repository or claim that its patched function is the result of a measured maintenance project. It models the strategy with an executed implementation. The question is whether that strategy can satisfy the declared API behavior, not how many engineer-days it would save in another organization.
The patched path makes the rounding and date choices explicit rather than relying on defaults. That matters because a default can be perfectly valid in general while being wrong for this API. The application contract decides which behavior must be preserved.
The patch also shares the fixture's validation boundary. Unsupported versions, invalid states, invalid dates, null account identifiers and negative amounts return structured HTTP 400 responses. Preserving the successful response alone would be an incomplete compatibility claim if the invalid-input behavior changed unexpectedly.
A patch becomes less attractive when it forces unrelated domain responsibilities into the same representation or makes future changes hard to express. The current experiment does not measure those long-term costs. It establishes a baseline that the other strategies must preserve before their design advantages become relevant.
Rewrite the domain and adapt the wire contract deliberately
01 / See the idea
Give compatibility an explicit owner
The adapter maps the internal enum and UTC date to the existing JSON contract. Its behavior is checked through real HTTP responses.
Interpretation and evidence
- Keep domain and wire types separate.
- Define the mapping explicitly.
- Validate the full HTTP boundary.
The rewritten implementation introduces DomainReceipt with an account value, decimal amount, ReceiptState enum, DateOnly UTC date and optional note. That type is an internal model. It is not automatically the public JSON contract.
The compatibility adapter translates the domain value into LegacyReceipt. It maps the enum back to the expected strings, formats the date with the declared representation, preserves the null note and supplies the version-one field names and version value.
This is the executed adapter:
static LegacyReceipt Adapt(DomainReceipt d)=>new(d.Account,d.Amount,d.State==ReceiptState.Queued?"queued":"accepted",d.UtcDate.ToString("yyyy-MM-dd",CultureInfo.InvariantCulture),d.Note,"1");
The adapter is intentionally explicit. Its purpose is to make the compatibility responsibility visible, not to hide it behind a serializer convention that can change when a domain property is renamed. A new domain model can then evolve under its own rules while the version-one response remains a reviewed interface.
That separation has a cost. There is another mapping to maintain and another place where a field could be omitted or misinterpreted. The test therefore executes the complete HTTP path through the adapter and compares it with independent golden payloads. The adapter is not declared correct merely because its constructor arguments look similar to the legacy DTO.
The fixture's rewritten calculation preserves the old behavior deliberately. It uses decimal midpoint rounding away from zero and derives the date from the UTC instant. A rewrite that intentionally changes those business semantics would need a new contract decision, not an adapter that silently conceals the change.
The result is a migration seam. The old route can select the patched implementation or the rewritten domain plus adapter while presenting the same version-one behavior. That seam is what makes a controlled switch and rollback possible in the later test.
Let the naive rewrite demonstrate a real compatibility failure
02 / A concrete example
HTTP 200 can still break the client
The comparison checks values and JSON shape, not only transport success. This intentional negative case proves the golden contract can reject a plausible rewrite.
Interpretation and evidence
- Status code is not compatibility.
- Null and absence can differ.
- Test dates and rounding at boundaries.
The negative rewrite returns an anonymous object with id instead of accountId, a numeric state instead of the status string, date instead of requestedDate and no version field. It uses default midpoint rounding and the offset-local calendar date. Its serializer is configured to omit null properties.
The endpoint still returns HTTP 200. The response is valid JSON and contains plausible-looking values. A shallow smoke test that checked only the status code and parseability would accept it.
For the first golden request, the actual naive response has amount 12.34, state zero and date 2026-11-12. The expected response requires amount 12.35, status queued and requestedDate 2026-11-11, along with the legacy field names, explicit null note and version string. The golden comparison detects the mismatch.
This is an intentional mutant, not a claim that every rewrite makes these mistakes. Its purpose is to prove that the test boundary can reject a plausible incompatible implementation. A test suite that accepts both the compatible adapter and this mutant would not protect the stated contract.
The null behavior is particularly easy to dismiss as cosmetic. System.Text.Json supports omitting null properties through WhenWritingNull, but that setting changes the serialized representation. Whether absence and explicit null are equivalent is an API decision, not something the serializer can decide for the client. Microsoft: ignoring JSON properties
The negative example combines several changes to make the failure obvious. It does not isolate the maintenance impact of each change or prove that the client would fail in exactly the same way for every one. The evidence establishes that this response violates the accepted version-one payload contract despite its successful HTTP status.
Treat dates and rounding as compatibility decisions
The amount uses decimal throughout the compatible paths. The selected midpoint rule is explicit. The first case, 12.345, distinguishes away-from-zero rounding from the default midpoint-to-even result at two decimal places. The second case, 2.675, provides another known boundary while also exercising the accepted state and a non-null note. The pinned runtime source exposes the selected decimal rounding behavior. Runtime Decimal source, v11.0.0
The point is not that one rounding rule is universally better. The API already promises a rule. A modernization that changes it can alter externally visible amounts even when the implementation looks mathematically reasonable. The golden input names the boundary so the repair is not left to a reviewer noticing a subtle default.
The requested timestamp has an offset, while the response requires a UTC date. The compatible paths convert the instant to UTC before deriving the date. The second case uses a negative offset late in the day and therefore produces the following UTC date. These two cases catch both directions of the calendar-boundary mistake. Runtime DateTimeOffset source, v11.0.0
The fixture does not model a local business calendar, daylight-saving policy or a time-zone identifier. A DateTimeOffset value supplies an offset for an instant, and this contract asks for its UTC date. A production rule based on a named business zone would need different inputs and tests.
The distinction matters because modernization often encourages replacing an awkward date representation with a cleaner type. DateOnly can be a useful internal representation here, but it does not decide which date should be derived from the original timestamp. The conversion rule must be preserved before the new type can represent the right value.
These are examples of semantic compatibility, not serialization trivia. Field names can remain unchanged while amounts and dates change meaning. A useful migration test includes both shape and value boundaries rather than assuming that a matching DTO name proves the client contract survived.
Keep the error contract in the migration
The fixture tests five invalid conditions through both the patched and adapter routes: unsupported version, invalid state, invalid date, null account identifier and negative amount. Each must return HTTP 400 with the expected error code.
The error codes are unsupported_version, invalid_state, invalid_date, invalid_account and invalid_amount. They are independently specified in the test cases rather than derived from whichever exception the implementation happens to throw. That makes the expected behavior reviewable at the client boundary.
The validation function is shared by the two compatible strategies. This keeps the comparison focused on representation and migration routing, but it also limits the proof. The experiment does not show that two independently rewritten validation systems agree. It shows that the shared validation boundary is preserved across the selected implementation paths.
The HTTP framework still performs its own binding and serialization work. The test uses real requests with JSON bodies and a version header, so the successful comparison includes those boundaries. It is stronger than directly invoking the mapping function, while remaining narrower than a complete deployed service with proxies and authentication.
The fixture does not test every malformed JSON document, omitted field or numeric overflow. Those may be important in a real API, but they are not implied by the five selected negative cases. The article reports the executed set rather than calling it exhaustive compatibility.
A migration plan should identify which error behaviors clients actually depend on. Some clients may branch on status codes, others on structured error identifiers. Changing those behaviors can be as disruptive as renaming a successful response field. A rewrite should not treat errors as an internal implementation detail when they cross the same public boundary.
Run shadow comparison without duplicating a business effect
04 / A concrete example
Shadow comparison needs an effect boundary
No external mutation is duplicated in this fixture. A production shadow design must separate computation from effects before executing two paths.
Interpretation and evidence
- Compare at a declared representation.
- Keep the established response authoritative.
- Do not shadow side effects blindly.
The shadow endpoint validates a request, computes the patched result and the rewritten-plus-adapted result, compares them and returns the patched result. The evidence records both representations and whether they agree. All three selected golden inputs produce equal shadow results.
This works safely in the fixture because the operation is a stateless transformation. It does not charge a card, send a message, insert a database row or call an external service. Evaluating both implementations therefore does not duplicate an externally meaningful effect.
That limitation is central to transferring the technique. A production shadow path cannot blindly execute two mutating workflows and call the second one observational. It may need a read-only projection, a captured input replay, a dry-run boundary or another design that separates computation from effects.
The comparison also has a declared equivalence rule. The HTTP golden checks ignore property order but preserve names, kinds and values. The in-process shadow comparison serializes two instances of the same legacy response type and compares their JSON. It is intentionally simple because both paths are already adapted to the same shape.
Zero differences across three cases is useful evidence for those cases, not proof that every possible input is equivalent. The fixture keeps the golden set small and transparent. A real shadow rollout would need representative traffic selection, a policy for sensitive data and an explanation of which differences are expected or unacceptable.
The main benefit is diagnostic locality. If the rewritten domain and adapter diverge from the patched path, the system can record the discrepancy while still returning the established result. That gives the team a concrete comparison before changing the public route. It does not eliminate the need to understand the contract that defines which result is correct.
Exercise the feature switch and the rollback through HTTP
03 / Watch the mechanism
Exercise the return path after the switch
Static view. Play to see how the mechanism unfolds.
The final patched stage verifies rollback after using the adapter. The marker shows sequence, not deployment speed.
Interpretation and evidence
- Test forward and reverse routing.
- Use the same client contract.
- Do not infer persistence rollback.
The laboratory exposes a loopback-only control endpoint that selects patched or adapter mode for the version-one route. The runner changes that mode through actual HTTP requests rather than assigning the variable directly from the test.
The sequence is patched, adapter, patched. Each stage executes all three golden requests. All nine responses match the independently specified payloads. The last stage is the rollback proof: after the route has used the new domain and adapter, switching back still produces the accepted version-one behavior.
This is an in-process feature flag, not a deployment platform or distributed configuration system. The test does not prove propagation across multiple servers, persistence across restart or race-free switching under concurrent traffic. It proves the selected routing seam in this single controlled application.
The endpoint is deliberately named as laboratory control and binds only to loopback. It is not an example of an unauthenticated administrative endpoint for production. A deployed feature-control surface would need its own authorization and audit design.
Rollback is straightforward here because the operation is stateless and both implementations consume the same request shape. A database migration that changes stored representations could make rollback more complicated or impossible without a compatibility period. No persistence migration is included, so the article does not imply that this flag solves that separate problem.
The test nevertheless demonstrates an important planning rule. A rollback path should be exercised after the forward switch, using the same external contract checks. Merely retaining old code or documenting a command to change a flag does not prove that the old path still works in the new state.
Read the executed result matrix without inventing productivity gains
05 / A concrete example
Report behavior, not invented productivity
The counts describe tested behavior. They do not rank long-term maintenance cost or developer speed.
Interpretation and evidence
- Keep proof aligned with the claim.
- Retain negative cases.
- Avoid unsupported business metrics.
The evidence contains nine golden HTTP responses across the forward and rollback sequence, ten negative HTTP responses across the two compatible strategies, three successful shadow comparisons and one expected naive-rewrite mismatch. The process exits successfully only when all these expectations hold.
| Boundary | Executed observations | Required outcome |
|---|---|---|
| Patched → adapter → patched route | 9 golden HTTP responses | Exact declared JSON contract |
| Invalid-input behavior | 10 HTTP responses | 400 with expected error code |
| Shadow comparison | 3 HTTP requests | Equal adapted legacy results |
| Naive rewrite | 1 HTTP request | Compatibility mismatch detected |
These counts are not estimates of maintenance cost. The laboratory does not measure how quickly a developer can change the module, how many incidents a rewrite prevents or whether one design is more productive for a team. Such claims would need different evidence and a much broader context.
There is also no performance ranking. The operation is tiny, and the task is to preserve behavior. Timing a few local requests would not answer whether the adapter is the right modernization strategy. The relevant acceptance evidence is the actual contract behavior, including refusal and rollback.
The matrix makes the tradeoff concrete without pretending to choose a universal winner. The patch preserves behavior directly. The rewritten domain plus adapter preserves the same behavior through an explicit compatibility seam. The naive rewrite demonstrates that a valid HTTP response can still violate the client's assumptions.
The choice between patch and adapter depends on the module's future responsibilities, deployment constraints and cost of maintaining parallel representations. The fixture supplies a way to verify either choice at the boundary. It does not supply a fabricated business case for rewriting every old module.
Decide which changes deserve a new API version
Not every externally visible difference is a bug. A product may intentionally choose a new status vocabulary, date meaning or null policy. The problem is introducing those differences under a route and version that promise the old behavior.
A new API version can make the change explicit, but versioning alone does not define its semantics. The new contract still needs expected payloads, error behavior and migration guidance for clients. A version-two response that merely exposes the current domain type may repeat the same coupling under another number.
The fixture rejects a version-two header on its version-one endpoint. It does not implement a complete version-two API. That rejection prevents the test from silently treating an unsupported version as though it had the same contract. A real extension would add a separate declared representation and its own tests.
The adapter offers a way to decouple internal modernization from that product decision. The domain can adopt an enum and DateOnly while the old wire contract remains stable. A later version can expose a different representation deliberately, when clients and deployment sequencing are ready for it.
This separation is particularly useful when several clients upgrade at different speeds. The module can share internal computation without forcing every client to accept a new JSON shape on the same day. The compatibility layer then becomes an explicit responsibility with a planned retirement condition rather than accidental permanent duplication.
That retirement condition should be based on actual client usage and supported-version policy, not on the new implementation being aesthetically preferable. Removing an adapter changes an external promise. The same discipline used to introduce it should govern its removal.
Keep the golden contract independent of the implementation
A golden response can become a weak test if it is regenerated automatically from whichever implementation currently runs. That workflow can make an incompatible rewrite update its own expected result and then pass. The fixture instead stores literal expected payloads chosen from the contract's arithmetic, date conversion and field rules.
The comparison is intentionally strict about property presence and JSON kinds. It is intentionally relaxed about property ordering and numeric formatting that preserve the same decimal value. Those choices define what compatibility means for this small client. A production client with a different tolerance needs an equally explicit comparison rule.
The naive rewrite proves that the rule is capable of rejecting a known violation. Its valid JSON and HTTP 200 are insufficient. The test notices the missing fields, changed type, altered rounding and different date. That negative result is as important as the green adapter results because it shows that the acceptance boundary is not merely ceremonial.
There are still shared implementation assumptions. The patched and rewritten paths use the same platform date parser, and both pass through the same validation function. The golden values reduce the risk of a shared semantic mistake, but they do not prove every parser edge case. The article therefore names the actual selected inputs rather than claiming complete behavioral equivalence.
When a real module is inherited, the contract may not be fully documented. Existing client behavior, captured examples and product decisions can all contribute to defining it. The important step is to separate established obligations from accidental behavior before deciding what a rewrite should preserve. A test can preserve a bug if nobody has decided whether that behavior is part of the supported contract.
The fixture avoids that ambiguity by declaring its rules first. It does not claim to have discovered a real legacy system's complete behavior. It demonstrates how a clear external contract can guide a modernization choice and how an executed negative case can expose a rewrite that crosses the boundary unintentionally.
Modernization is a controlled change in responsibility
The strongest result of this exercise is not a new abstraction. It is an implemented seam that allows internal change while preserving a tested HTTP contract. The seam can be selected, compared and rolled back, and a plausible incompatible rewrite is rejected by the same client-facing evidence.
The limits remain explicit. The fixture is stateless, single-process and loopback-only. It does not test database migration, distributed feature propagation, authentication, concurrent cutover or human maintenance effort. Those concerns can be added when they become part of the intended modernization claim.
The transferable method is to begin with the client's entitlement, implement the smallest coherent strategy, preserve a comparison path and verify the actual boundary. A cleaner domain model is valuable when it makes responsibilities easier to express. It is not permission to redefine every external representation by accident.
The module has been modernized successfully when the team can explain what changed internally, what remained promised externally and which executed checks support both statements. The old clients should not have to discover the difference for us.
Source code and execution evidence
Download the complete lab, source record and raw execution results. This package supports the specific execution claims in this article. It is not a production certification.