> ## Content Index
> Fetch the complete content index at: https://devinside.cloud/llms.txt
> Use this file to discover other available public pages before exploring further.

# Rewrite the inside. Protect the wire.
- URL: https://devinside.cloud/modernize-the-module-without-accidentally-rewriting-its-clients/
- Published: 2026-11-12T15:49:29.000Z
- Updated: 2026-11-13T00:58:17.000Z
- Description: The rewrite is cleaner. A string status becomes an enum. Null properties disappear. A timestamp is converted to UTC. A decimal uses a new rounding default. Every individual change looks defensible.
- Author: Marcin Borecki
- Tags: Engineering, .NET and data engineering, #eng12, #devinside-review

Historical edition · Sources and examples scoped to 2026-04-16.

The rewrite is cleaner. A string status becomes an enum. Null properties disappear. A timestamp is converted to UTC. A decimal uses a new rounding default. Every individual change looks defensible.

The old client receives HTTP 200 and behaves differently.

That is the uncomfortable boundary in modernization: internal improvement can produce an external contract change even when the route, method name and return type appear stable. Compatibility is what the client can observe, not what the new domain model intends.

Our example uses a deliberately small response. The client expects a status string, a date derived from the request's original offset, a specific midpoint rule and an explicit null note. The revised domain model can use better internal names and representations, but an adapter must decide how those map back to the accepted wire contract.

Try the migration visual. Compare the legacy-shaped response, the adapted rewrite and the naive rewrite. Change the offset and the rounding input. The differences are computed from the selected policy, not shown as a screenshot of a later HTTP experiment.

The sample timestamp is in March of this edition's historical year. It is fixture data, not a claim about when a test ran. The interactive uses a simplified response model to expose the observable differences that a migration test should protect.

## Begin with the observation the client relies on

Suppose the accepted response contains these fields:

```json
{
  "status": "accepted",
  "requestedDate": "2026-03-01",
  "amount": "1.01",
  "note": null
}

```

A client may distinguish a missing field from an explicit null. It may compare status values against a fixed vocabulary. It may interpret `requestedDate` as the date supplied by the caller's offset rather than the UTC date. These choices should be part of the contract, even if an internal implementation once made them accidentally.

The first task is not to preserve every undocumented accident forever. It is to discover which behaviors exist, identify which clients depend on them, and decide which will remain stable under the old contract. A deliberate breaking change can be appropriate. A silent one is harder to operate.

![Internal shape and wire shape have owners](https://devinside.cloud/content/images/2026/11/devinside/v4/ENG12/domain-wire.svg)

[Inspect the full diagram ↗](https://devinside.cloud/content/images/2026/11/devinside/v4/ENG12/domain-wire.svg)Swipe the drawing to inspect it.

The old implementation can supply examples, but it should not be the only oracle. If it contains a defect, blindly recording every output as golden may preserve that defect without a decision. Review representative requests and state the expected result independently where the semantics matter.

## A date is a projection of an instant and a rule

Take `2026-03-01T00:30:00+02:00`. In UTC, that instant falls on the previous day. Returning the caller's date and returning the UTC date are both mechanically possible. They answer different questions.

```csharp
DateTimeOffset requestedAt = request.RequestedAt;

string callerDate = requestedAt.Date.ToString(
    "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture);
string utcDate = requestedAt.UtcDateTime.Date.ToString(
    "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture);

```

The invariant culture keeps this Gregorian wire representation separate from the process's display culture. The adapter must choose the projection promised by the old API. Normalizing the internal representation to UTC does not require changing a field whose meaning was the caller-local date. Conversely, if the field was always defined as a UTC date, preserving the original offset's date would be the defect.

The [.NET 9.0.4 DateTimeOffset source](https://github.com/dotnet/runtime/blob/v9.0.4/src/libraries/System.Private.CoreLib/src/System/DateTimeOffset.cs?ref=devinside.cloud) is a versioned reference available before this edition's context. The example relies on established date/offset semantics, not a feature introduced by the current laboratory's newer runtime.

![One instant, two different dates](https://devinside.cloud/content/images/2026/11/devinside/v4/ENG12/date-projection.svg)

[Inspect the full diagram ↗](https://devinside.cloud/content/images/2026/11/devinside/v4/ENG12/date-projection.svg)Swipe the drawing to inspect it.

## Rounding can change without changing the input type

For a decimal value halfway between two hundredths, different midpoint rules can produce different results. The value `1.005m` rounded to two places can become `1.00` under a ties-to-even rule and `1.01` under an away-from-zero rule.

```csharp
decimal wireAmount = decimal.Round(
    domain.Amount, 2, MidpointRounding.AwayFromZero);

```

If the historical contract specifies the latter, the adapter should state it. A developer may prefer another rule for a new product, but that preference does not make it compatible with an existing client.

The [historical Decimal implementation](https://github.com/dotnet/runtime/blob/v9.0.4/src/libraries/System.Private.CoreLib/src/System/Decimal.cs?ref=devinside.cloud) provides the operation's reference boundary. The illustration is not a recommendation for financial calculations in general. It is a demonstration that a policy choice can survive inside a seemingly ordinary numeric conversion.

Formatting has a separate responsibility. If the wire contract uses a string with exactly two places, changing to a JSON number can alter lexical output even when the mathematical value is equal. Decide whether that distinction is part of the accepted client contract before declaring a serialization change harmless.

## The adapter can be explicit without infecting the domain

The rewritten domain may have an enum named `Ready`, an optional internal note and an instant stored in normalized form. A boundary adapter can translate those into the legacy vocabulary and shape.

```csharp
public sealed record LegacyReply(
    string Status,
    string RequestedDate,
    string Amount,
    string? Note);

```

This type is not meant to become the new domain. It is a statement of responsibility at the boundary. Keeping it separate lets the internal model evolve without making every domain decision immediately observable to old clients.

![Preserve the boundary without freezing the inside](https://devinside.cloud/content/images/2026/11/devinside/v4/ENG12/adapter-seam.svg)

[Inspect the full diagram ↗](https://devinside.cloud/content/images/2026/11/devinside/v4/ENG12/adapter-seam.svg)Swipe the drawing to inspect it.

That separation also gives the migration a place to fail clearly. An internal state with no valid legacy representation should not be converted to a guessed string. It may require a refusal, an explicitly versioned response or a product decision about compatibility.

## Null, omission and error are different messages

Removing null properties can make JSON smaller and cleaner. It can also change a client's interpretation. A missing note might mean “this version does not support notes,” while an explicit null might mean “notes are supported but no value exists.” The application should not invent those semantics, but it must investigate them before changing the representation.

The error contract deserves the same care as the success body. A rewrite that turns a validation refusal into an internal exception may return a different status, error code or retry signal. A client that safely stopped under the old response may now retry an operation that should not be repeated.

Write golden cases for invalid input, unknown enum values, missing fields and boundary dates. Success-only comparisons are a weak form of compatibility testing because many integration failures live in the paths the happy demo never visits.

## Shadow comparison must not duplicate the effect

A shadow path can compare old and new calculations for the same request. If both paths perform a business effect, the comparison itself can create a duplicate operation. Separate pure interpretation or calculation from the effect boundary before introducing dual execution.

For a read or pure calculation, compare normalized outputs according to the contract. For a write, compare the proposed command or a dry-run decision where that concept is meaningful, and let one authorized path own the effect. A shadow system is not automatically harmless because its response is hidden from the user.

![Compare decisions without repeating effects](https://devinside.cloud/content/images/2026/11/devinside/v4/ENG12/shadow-boundary.svg)

[Inspect the full diagram ↗](https://devinside.cloud/content/images/2026/11/devinside/v4/ENG12/shadow-boundary.svg)Swipe the drawing to inspect it.

The comparison should retain enough context to reproduce a mismatch while respecting data-handling constraints. Record the contract version, selected route and the relevant difference. Do not treat every property-order change as a semantic failure if the contract does not specify order, and do not normalize away a difference that the client actually relies on.

## Rollback is a behavior to exercise

A feature switch that changes the selected implementation is useful only if both paths remain operational under the current data and dependencies. New writes may introduce states the old implementation cannot interpret. A schema migration may remove a column the rollback path still needs.

Test the switch in both directions with a defined workload. Verify the response contract and any persistent effects after switching back. Calling a configuration value a rollback mechanism does not establish that the earlier behavior can still run.

The interactive response model makes the narrowest version of this question visible: choosing the compatibility adapter restores the declared fields and semantics. It does not simulate database schema changes, distributed deployment or a production rollback. Those need their own integrated evidence.

## What would make a new API version preferable?

An adapter is not a moral obligation to preserve a bad contract forever. If the old date meaning is misleading, the status vocabulary is too narrow or omission semantics prevent useful evolution, a new version may be the clearer choice.

The decision should be explicit. Explain which behavior changes, how clients opt in, what remains supported and how the system detects callers still relying on the old meaning. A clean new domain model is a reason to consider a better contract; it is not proof that the old contract can be changed invisibly.

For a modernization review, I would ask for independently specified cases, a visible adapter seam and an exercised rollback boundary. Code style matters, but these are the artifacts that tell us whether the rewrite improves the module without quietly rewriting its clients.

GO FURTHER

## Inspect the contemporary laboratory.

This is a newly reconstructed historical edition, not a claim of earlier authorship. Later executed experiments, their exact environments and original results are preserved in a separate notebook.

[Open the current laboratory notebook ↗](https://devinside.cloud/lab-notebooks/v4/ENG12.html)

[Download the current reconstruction sources ↗](https://devinside.cloud/content/files/v4/ENG12-reconstruction.zip)