If you've ever shipped an API and then needed to rename a property two weeks later, you already know the pain: clients don't upgrade all at once.
That means your JSON contract has to evolve without breaking old consumers. The good news is you don't need a giant rewrite to do it. A few deliberate patterns in System.Text.Json go a long way.
Let's walk through practical options I keep reaching for.
Start with additive changes
The safest move is adding new properties while keeping old ones for at least one transition window.
using System.Text.Json.Serialization;
public sealed class CustomerResponse
{
// Existing contract (v1)
public string FullName { get; init; } = string.Empty;
// New contract (v2)
public string? GivenName { get; init; }
public string? FamilyName { get; init; }
}
If old clients only read FullName, they still work. New clients can opt into the richer shape when they're ready.
Handle renamed input with [JsonExtensionData]
Sometimes you need to accept both old and new property names during a migration.
using System.Text.Json;
using System.Text.Json.Serialization;
public sealed class UpdateProfileRequest
{
[JsonPropertyName("displayName")]
public string? DisplayName { get; init; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? Extra { get; init; }
public string? ResolveLegacyDisplayName()
{
if (!string.IsNullOrWhiteSpace(DisplayName))
{
return DisplayName;
}
if (Extra is not null && Extra.TryGetValue("name", out var legacyName))
{
return legacyName.GetString();
}
return null;
}
}
This lets you accept displayName (new) and name (legacy) without fragile manual JSON parsing.
Write both shapes with a custom converter
When you must output old or new JSON shape based on API version, a converter keeps that logic in one place.
using System.Text.Json;
using System.Text.Json.Serialization;
public enum ContractVersion { V1, V2 }
public sealed record Money(decimal Amount, string Currency);
public sealed class MoneyConverter(ContractVersion version) : JsonConverter<Money>
{
public override Money Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
using JsonDocument doc = JsonDocument.ParseValue(ref reader);
JsonElement root = doc.RootElement;
decimal amount = root.GetProperty("amount").GetDecimal();
string currency = root.TryGetProperty("currencyCode", out var code)
? code.GetString() ?? "USD"
: root.GetProperty("currency").GetString() ?? "USD";
return new Money(amount, currency);
}
public override void Write(Utf8JsonWriter writer, Money value, JsonSerializerOptions options)
{
writer.WriteStartObject();
writer.WriteNumber("amount", value.Amount);
if (version is ContractVersion.V2)
{
writer.WriteString("currencyCode", value.Currency);
}
else
{
writer.WriteString("currency", value.Currency);
}
writer.WriteEndObject();
}
}
var v1Options = new JsonSerializerOptions();
v1Options.Converters.Add(new MoneyConverter(ContractVersion.V1));
var v2Options = new JsonSerializerOptions();
v2Options.Converters.Add(new MoneyConverter(ContractVersion.V2));
Now version-specific behavior is explicit and testable.
Deprecate with telemetry, not guesswork
I used to remove legacy fields based on calendar dates alone. That's risky.
A better approach is:
- mark legacy fields as deprecated in docs/OpenAPI
- log when legacy fields are read
- remove them only after usage is near zero
If you can measure usage, you can remove old contracts confidently instead of hoping nobody notices.
A small migration checklist
When changing JSON contracts, I keep this checklist nearby:
- prefer additive changes first
- accept old + new input names during transition
- centralize serialization branching in converters
- version endpoints only when shape differences are substantial
- remove legacy properties only after telemetry proves they're unused
Schema evolution is less about clever code and more about boring consistency. That's a good thing.
Your API can move fast and stay stable — as long as you treat contracts as long-lived agreements, not temporary implementation details.
