Skip to content
Kumar Chandrachooda
.NET

Five Ways a Property Can Change

Rename, move, add, remove, type change - VersionKit's attribute surface covers five kinds of schema evolution. This part walks the whole declarative API, the merge rules behind stacked attributes, and what the attributes deliberately refuse to express. Part 2 of the Entity Versioning with VersionKit series.

By Kumar Chandrachooda 28 Jul 2025 5 min read
One property, five different ways its history can be written down

Part 1 made the pitch: your serialized payloads remember every property name you have ever shipped, and VersionKit turns that history into declared metadata instead of tribal knowledge. This part works through the whole declarative surface — the two attributes, the five change kinds they can express, and the merge rules that decide what happens when a property has been through more than one change.

The version lives on the class

Everything starts with [SchemaVersion]:

[SchemaVersion(3)]
public class Order
{
    public Guid Id { get; set; }
    ...
}

The number is the shape the class has right now. Every payload VersionKit writes carries it as a "__version" watermark, and every payload it reads is compared against it. The constructor refuses anything below 1 — versions start at 1, always:

public SchemaVersionAttribute(int current)
{
    if (current < 1)
        throw new ArgumentOutOfRangeException(nameof(current),
            "Schema version must be >= 1.");
    Current = current;
}

When do you bump it? The XML docs are specific: rename, remove, move, or type-change a property and you increment. A genuinely non-breaking addition — a new nullable field that no old payload could possibly contain — does not force a bump if you give it a default. That distinction matters in practice: teams that bump on every commit end up with forty versions and no story; teams that never bump end up back in JsonPropertyName hackland.

The five change kinds

Property history goes on the property, via [PropertyVersion]. The sample Order in the repo demonstrates all five kinds across three versions, and it is worth seeing them together because the pattern is always the same: what happened, plus the version it happened in.

Rename. The value survives; the key changes.

// v1 payloads store this as "ClientId"
[PropertyVersion(AddedIn = 1, RenamedFrom = "ClientId", RenamedInVersion = 2)]
public Guid CustomerId { get; set; }

Move. A flat field was restructured under a new name:

// v1 payloads: { "ShippingCity": "London" }
[PropertyVersion(AddedIn = 1, MovedFrom = "ShippingCity", MovedInVersion = 2)]
public string City { get; set; } = default!;

Honesty checkpoint: today, MoveOperation and RenameOperation do exactly the same thing — take the value out from under the old key, put it under the new one. The XML documentation gestures at nested paths (Address.City), but the operation does not yet write into nested objects. Move exists as a semantically distinct declaration — “this was restructured, not just renamed” — which keeps the model history readable and leaves room for nested support later. If you need a genuine restructure into a child object today, that is hand-written-migrator territory (Part 5).

Add with a default. Old payloads simply lack the field, so you declare what it should have been:

// v1 payloads are missing "Status" — default to "Pending" on read
[PropertyVersion(AddedIn = 2, DefaultValue = "Pending")]
public string Status { get; set; } = default!;

C# attributes only accept compile-time literals — strings, numbers, bools, null. The moment your default depends on other data, you switch to a provider type:

// derived from Total — old payloads get the value computed on read
[PropertyVersion(AddedIn = 3, DefaultProvider = typeof(IsHighValueDefaultProvider))]
public bool IsHighValue { get; set; }

The provider is a small class implementing IDefaultValueProvider, and it receives the whole payload so it can derive the answer — the sample computes IsHighValue from Total. Part 5 digs into the contract; the headline here is that the split between DefaultValue and DefaultProvider is forced by the CLR, not by taste.

Two subtleties hide in the scanner's handling of defaults. First, the default only fires for properties with AddedIn > 1 — a v1 property never needs one, because every payload that exists has it. Second, the scanner checks DefaultValue is not null, which means an explicit null default is indistinguishable from no default at all. In practice this is fine — a missing key deserialises to null anyway — but it is the kind of edge you want written down.

Remove. The property is dead but old payloads still carry it:

// v1/v2 payloads contain "LegacyNotes" — strip it when migrating to v3
[PropertyVersion(AddedIn = 1, RemovedInVersion = 3)]
public string? LegacyNotes { get; set; }

Note that the CLR property stays on the class, attribute and all. That feels wrong the first time — surely removed means deleted? — but the attribute is the only record of the removal, and VersionKit's EF Core integration (Part 7) reads it to un-map the property from the current table. The property becomes pure history: it exists so the metadata has somewhere to live.

Type change. The gnarliest of the five, because a value has to be converted, not just relocated:

// v1/v2 payloads: { "IsExpedited": "true" }   — a string
// v3+ payloads:   { "IsExpedited": true }     — a bool
[PropertyVersion(AddedIn = 1, TypeChangedInVersion = 3,
                 TypeMigrator = typeof(StringToBoolMigrator))]
public bool IsExpedited { get; set; }

You supply the conversion as a tiny class inheriting TypeMigratorAdapter<TFrom, TTo>:

public sealed class StringToBoolMigrator : TypeMigratorAdapter<string, bool>
{
    public override bool Migrate(string oldValue) =>
        oldValue?.Trim().ToLowerInvariant() is "true" or "1" or "yes";
}

Attributes cannot carry lambdas, so — same as the default provider — the conversion lives in a named type referenced by typeof. The adapter base class handles the untyped plumbing, including the JsonElement coercion that System.Text.Json makes necessary (Part 5 covers why).

Stacked attributes and the first-wins merge

[PropertyVersion] is declared AllowMultiple = true, so a property that changed more than once stacks its history:

[PropertyVersion(AddedIn = 1, RenamedFrom = "ClientRef", RenamedInVersion = 2)]
[PropertyVersion(TypeChangedInVersion = 4, TypeMigrator = typeof(StringToGuidMigrator))]
public Guid CustomerId { get; set; }

At startup the scanner merges the stack into one record per property, and the merge rule is worth knowing precisely: first non-empty wins, per field. From the scanner:

foreach (var attr in attrs.Skip(1))
{
    renamedFrom ??= attr.RenamedFrom;
    if (renamedInVersion == 0 && attr.RenamedInVersion != 0)
        renamedInVersion = attr.RenamedInVersion;
    typeMigrator ??= attr.TypeMigrator;
    // ... same pattern for move, remove, defaults
}

The practical consequence: a property can carry one rename, one move, one type change, one removal, and one default across its whole lifetime — one of each kind, in any combination, but not two renames. If ClientRef became ClientId in v2 and then CustomerId in v4, the attribute model cannot say so; the second rename silently loses. This is a real limitation and I hit it designing the API: expressing ordered multiples of the same change kind would need list-typed attribute data and version-keyed pairs, and the complexity did not pay for itself when the escape hatch — a hand-written migrator for the step that needs it — already existed. My rule of thumb: attributes for the property's latest transition of each kind, hand-written migrators when history gets baroque.

A calmer example

Not every entity is an Order with battle scars. The sample's Product shows the common case — two versions, two declarations:

[SchemaVersion(2)]
public class Product
{
    public Guid Id { get; set; }
    public string Sku { get; set; } = default!;
    public decimal Price { get; set; }

    [PropertyVersion(AddedIn = 1, RenamedFrom = "ProductName", RenamedInVersion = 2)]
    public string Name { get; set; } = default!;

    [PropertyVersion(AddedIn = 2, DefaultValue = "Uncategorised")]
    public string Category { get; set; } = default!;
}

That is the whole migration story for Product: no Up() method, no backfill script, no serializer converters. A v1 payload with "ProductName": "Widget Pro" and no Category deserialises in v2 code as Name = "Widget Pro", Category = "Uncategorised" — because the class says so, in the one place a future maintainer will actually look.

What the attributes refuse to say

The declarative surface is deliberately narrow. It cannot split one field into two, merge two into one, make decisions (“if the total is negative, it was a refund”), call a lookup, or express conditional defaults beyond what a provider can derive from the payload itself. Every one of those is possible — through IHandWrittenMigrator, which takes over an entire version step — but they are code, reviewed as code, not metadata. Keeping the attribute language small is what keeps it trustworthy: if [PropertyVersion] compiles, the scanner can compile it into operations with no surprises.

And that compilation step — how five attribute declarations become an executable pipeline of operations, bucketed by version, at startup — is exactly where the next part picks up.

Next: Nobody Writes an Up() Method — inside the scanner that turns attributes into migration steps.