Modernization
Stop a legacy save from erasing new JSON fields
A .NET migration can pass its traffic-switch test and still lose data on the next legacy edit. Preserve the shared document, then rehearse the complete write-back path.
An ASP.NET Core feature saves a new field. Operations returns traffic to the .NET Framework application. The old screen opens the record successfully, and its next ordinary save removes that field.
Consider a hypothetical delivery-address editor. Both applications replace the same JSON document in an order record. Core adds an optional delivery note; Framework still edits the recipient and phone number. The migration has three constraints:
- A phone edit must preserve the delivery note already accepted from the customer.
- Records created before the new field existed must remain editable.
- The retained Framework release must satisfy both conditions if traffic returns to it.
In this example, a separate fulfillment reader already consumes deliveryNote. The agreed recovery behavior allows the legacy editor to leave the note uneditable, but requires it to preserve the stored value. If recovery must also support editing that note, add that capability to Framework before treating it as a usable return path.
This article follows that one write path. It first fixes how the legacy serializer carries unfamiliar fields, then makes the recovery test exercise a legacy save after a modern write. The example is hypothetical; the serializer behavior is tied to primary sources and a bounded executable harness.
Find the loss at the read–modify–write boundary
Suppose Core uses System.Text.Json to persist this document:
{
"recipient": "Receiving desk",
"phone": "555-0100",
"deliveryNote": "Use loading bay 3."
}The Framework model contains only recipient and phone. With Json.NET’s default MissingMemberHandling.Ignore, deserialization does not reject the unfamiliar deliveryNote property. That makes the read appear compatible. The setting’s default is explicit in the Newtonsoft.Json 13.0.4 source.
Now the old application changes phone and serializes that typed object to replace the stored JSON. Its object has nowhere to retain the note. The resulting document contains only the known properties:
{
"recipient": "Receiving desk",
"phone": "555-0199"
}A successful read therefore answers too little. For this feature, compatibility means that the old application can edit the fields it owns while retaining the fields it does not own. A schema migration may never run: the column can remain exactly the same while the document contract changes.
Trace the actual save. If it updates one JSON path without reconstructing the document, this particular loss mechanism may not apply. If it deserializes a persistence DTO, maps through another object, then replaces the whole document, every transformation belongs in the compatibility test.
Give the legacy persistence model somewhere to keep the note
Json.NET supports an extension-data member for properties without a matching typed member. On serialization, those entries become properties of the document again. Its 13.0.4 attribute implementation enables both reading and writing by default; the official sample shows a private dictionary of JToken values.
The reproducible example uses a .NET 10 console host with Newtonsoft.Json 13.0.4. The model and edit function belong to the legacy side; running them on modern .NET isolates serializer behavior and does not substitute for testing the Framework deployment.
Put the compatibility change in the Framework persistence model before enabling Core’s new field. This C# model uses Newtonsoft.Json’s attribute, not the similarly named System.Text.Json attribute:
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
public sealed class LegacyAddress
{
[JsonProperty("recipient")]
public string Recipient { get; set; } = "";
[JsonProperty("phone")]
public string Phone { get; set; } = "";
[JsonExtensionData]
private IDictionary<string, JToken> additionalData =
new Dictionary<string, JToken>();
}The dictionary retains the key, but token parsing still matters. On the harness host, the date-shaped note 2026-10-06T10:00:00+01:00 came back as 2026-10-06T05:00:00-04:00 under default settings. The exact rewritten text depends on the host’s local time zone. Those values represent the same instant, but this field is customer text. Json.NET’s DateParseHandling.None keeps date-shaped values as strings. Apply it locally to this persistence path:
static string EditLegacyPhone(string storedJson, string phone)
{
var settings = new JsonSerializerSettings
{
MissingMemberHandling = MissingMemberHandling.Ignore,
DateParseHandling = DateParseHandling.None
};
var address = JsonConvert.DeserializeObject<LegacyAddress>(storedJson, settings)
?? throw new InvalidOperationException("Address object required.");
address.Phone = phone;
return JsonConvert.SerializeObject(address, settings);
}These options override two settings; they do not create an isolated serializer. JsonConvert also uses JsonConvert.DefaultSettings, so include inherited converters and the contract resolver when checking the recovery package’s actual configuration.
Constructing a new LegacyAddress with only Recipient and Phone loses the dictionary again. Adding the attribute to an API response type also does nothing for a different persistence DTO used during saving. Follow the object that is actually written.
MissingMemberHandling is explicit here because the intended stored-document contract allows unfamiliar fields. Setting it to Error rejects an unknown property before the extension-data path handles it, as the 13.0.4 deserializer source and the harness show. Keep these settings at this persistence boundary; do not loosen validation across the entire HTTP API.
Keep this model internal to the persistence path. Carrying approved stored fields through an edit does not authorize accepting arbitrary customer-supplied properties or exposing the extension dictionary through an API. Existing input validation, payload limits and authorization still apply.
Deploy the preservation change before the new writer
The ordering matters because a compatible latest branch does not repair an older recovery package. Use this release sequence:
- Deploy the legacy preservation change while Core’s delivery-note writes remain disabled. Verify existing records and ordinary legacy edits.
- Make that tested Framework build the minimum supported recovery artifact. Record its package, serializer settings and identity; an earlier build remains unsafe for the new document.
- Enable the Core field only after every writer that can replace this document is compatible, including retained jobs and import tools.
- Keep the preservation behavior for the agreed coexistence and recovery period. Retire it deliberately when those writers and recovery obligations end.
Once documents contain the new field, turning its feature flag off does not make an earlier recovery build safe. Keep the compatible build as the minimum recovery version while those documents remain.
This imposes real work on the legacy application. If that application cannot be changed, keep the new field disabled, retain a compatible authoritative writer, or separate storage so legacy saves cannot overwrite it. Choose from the actual write ownership; a routing flag cannot supply the missing data behavior.
The existing migration guide covers conditional endpoint selection. Here, the release gate is narrower: new persisted fields must not become visible to an incompatible writer. Microsoft’s safe-deployment guidance separately calls out the complexity of reversing stateful changes and recommends versioned build artifacts.
Make the recovery test fail on the unsafe save
Start with a serializer test that makes the failure cheap to reproduce. The snippets form one Program.cs: put all using directives first, the top-level test below next, then EditLegacyPhone and the LegacyAddress class. Reference Newtonsoft.Json 13.0.4 in a .NET 10 console project and run dotnet run. System.Text.Json writes the modern document and independently reads the saved result. The string variable stands in for persistence; this does not exercise a database or either web application.
using System;
using System.Text.Json;
using Stj = System.Text.Json.JsonSerializer;
string stored = Stj.Serialize(new
{
recipient = "Receiving desk",
phone = "555-0100",
deliveryNote = "Use loading bay 3."
});
stored = EditLegacyPhone(stored, "555-0199");
using var saved = JsonDocument.Parse(stored);
var root = saved.RootElement;
if (root.GetProperty("recipient").GetString() != "Receiving desk")
throw new Exception("The recipient changed.");
if (root.GetProperty("phone").GetString() != "555-0199")
throw new Exception("The phone edit was not saved.");
if (!root.TryGetProperty("deliveryNote", out var note) ||
note.GetString() != "Use loading bay 3.")
throw new Exception("The legacy save lost the delivery note.");
Console.WriteLine("PASS: phone changed; recipient and note preserved.");Run the same assertions against the original legacy model with no extension-data member. The phone check should pass and the note check should fail. That negative control establishes that the test detects the defect being fixed. It should fail again if the save reconstructs a fresh object containing only the known fields.
Then test the compatible model with a missing note, explicit null, non-ASCII text, and repeated edits. Assert field presence separately from a null value where that distinction is part of the contract. Assert the edited phone and unchanged recipient too. Checking only the note could let a save that did nothing appear correct.
The serializer harness ran with .NET SDK 10.0.101, runtime 10.0.1, System.Text.Json 10.0.1 and Newtonsoft.Json 13.0.4. The original model and reconstructed-object controls lost the note as expected. The compatible path preserved ordinary text, null and non-ASCII notes, handled a historical record with no note, and survived ten sequential edits. The date-shaped string changed with default parsing and was preserved exactly with DateParseHandling.None. This was an in-process serializer test using string persistence; it did not run Framework, a database, YARP, a web application or the actual recovery package.
Rehearse the version you would actually restore
The serializer test establishes one mechanism. The release test must cross the real application boundary: Core writes the fixture to an isolated store; traffic is returned to the retained Framework build; Framework edits the phone and saves; a fresh read checks the stored document and confirms that the existing fulfillment reader still receives the accepted delivery note.
Use the actual database engine and document mapping, the exact recovery build, and its deployed serializer configuration. Do not recompile old source against today’s shared DTO and call that a test of the old package. Record which implementation served the edit so a proxy misconfiguration cannot send the test back to Core.
Version fidelity is a practical requirement. Json.NET issue #2708 reported a deserialization regression when moving from 12.0.3 to 13.0.1 with a particular constructor, DataContract and extension-data combination. That report concerns a different failure from the hypothetical field loss here. It illustrates why one successful plain-DTO test cannot establish the behavior of another model and package combination; it is not evidence that the issue persists in 13.0.4.
For this feature, retain evidence of four transitions:
- Historical document → legacy edit: the phone changes and the old contract remains valid.
- Modern document → legacy read: the retained build can open it.
- Modern document → legacy edit → fresh read: the note and other protected fields survive, and the existing fulfillment reader still receives the accepted note.
- Same document through the configured traffic-return path: the intended Framework build performs the edit and the saved result still meets the assertions.
Test the save while the migration flag is off, and exercise every path that can replace this document. If one import job still discards unknown fields, the field is not ready for that shared store even when both screens pass.
Stop where preservation no longer settles the decision
Extension data preserves information through this serial read-modify-write path. It does not make the legacy application understand a new rule. If Core adds requiresSignature and Framework can still dispatch without a signature, retaining that property leaves the business behavior unsafe. Keep the capability gated until every permitted execution path honors it, or explicitly end that recovery option.
The dictionary also does not prevent stale whole-document writes. If Core changes the note after Framework reads its copy, Framework can still save the old note over the new one. Keep the application’s concurrency control and test that race separately; this example makes no concurrent-update guarantee.
Finally, the fixture covers a note at one document level, not arbitrary JSON. Nested typed objects need their own preservation boundary. The local date setting protects text, but converters and numeric parsing still need contract-specific tests. Json.NET’s extension-data tests show floating values parsed as Double by default and a Decimal alternative. Do not infer byte-for-byte or every-value preservation from the note fixture.
The release owner can enable the new field when the retained writers preserve it, the exact recovery build passes the end-to-end edit, and the feature’s behavior remains acceptable on that path. If any condition fails, keep the field gated and name the writer or rule that needs work. That gives the migration a specific next step without turning a successful traffic switch into a data-safety claim.
Sources
- Newtonsoft.Json 13.0.4 serializer settings
- Newtonsoft.Json 13.0.4 extension-data attribute
- Json.NET extension-data sample
- Newtonsoft.Json 13.0.4 deserializer
- Microsoft safe-deployment guidance
- Json.NET issue #2708
- Newtonsoft.Json 13.0.4 extension-data tests
- Newtonsoft.Json 13.0.4 package
- Newtonsoft.Json 13.0.4 date parsing
- Newtonsoft.Json 13.0.4 date-offset handling
- Newtonsoft.Json 13.0.4 default-setting inheritance