Message Payload Versioning

Evolve a topic's payload without breaking existing producers. Benzene gives you two tools for this, and they compose: route to a version-specific handler, or keep one handler and cast older/newer payloads around it (with multi-step version chains handled for you).

The version is metadata, never part of the body — it travels in the benzene-version header (a message attribute on SQS/SNS, a header on HTTP and the envelope). A message with no version is treated as the topic's default. The language-neutral rules live in the versioning specification; this cookbook is the .NET how-to.

Runnable, tested reference. Everything here is demonstrated end to end in examples/Versioning — one AWS Lambda StartUp over four transports, with 11 tests that boot the real startup via BenzeneTestHost. It's built and its tests run in CI (examples-build), so it stays honest. Read it alongside this page.

When to use which

Mechanism A — handler-version dispatch Mechanism B — transparent casting
Shape Two versions are genuinely different logic Newer versions mostly add fields
You write One handler per version One handler, on the newest schema
The framework Routes by version to the right handler Up/down-casts the payload around the handler
Reach for it when v2 does something v1 doesn't v2 is v1 plus a field, and you don't want to fork the handler

You can use both in one service, on different topics — they don't interfere (a topic with no casters is never cast; a topic with one handler is never version-routed).

Mechanism A — route to a version-specific handler

Give each handler the same topic but a different version, via the second argument of [Message]:

[Message("order:create", "v1")]
public class CreateOrderV1Handler : IMessageHandler<V1.CreateOrder, V1.OrderAccepted>
{
    public Task<IBenzeneResult<V1.OrderAccepted>> HandleAsync(V1.CreateOrder request) { /* ... */ }
}

[Message("order:create", "v2")]
public class CreateOrderV2Handler : IMessageHandler<V2.CreateOrder, V2.OrderAccepted>
{
    public Task<IBenzeneResult<V2.OrderAccepted>> HandleAsync(V2.CreateOrder request) { /* ... */ }
}

The router reads the incoming version with IMessageVersionGetter<TContext> (the default HeaderMessageVersionGetter scans benzene-versionversionx-version) and hands it to IVersionSelector. The default selector returns the handler for the exact version if one exists, otherwise the highest registered version. So:

That's all the wiring: register the handlers' assembly (AddMessageHandlers(...)) as usual. No caster config is involved.

Mechanism B — one handler, transparent casting (with chaining)

Keep a single handler on the newest schema and let Benzene cast older payloads up to it, and the response back down to the caller's version — all from one call, AddPayloadVersioning:

services.UsingBenzene(x => x
    .AddBenzene()
    .AddMessageHandlers(typeof(StartUp).Assembly)
    .AddContextItems()
    .AddPayloadVersioning(versioning => versioning
        // the transports this service should cast for
        .ForContext<BenzeneMessageContext>()
        .ForContext<ApiGatewayContext>()
        .ForContext<SqsMessageContext>()
        .ForContext<SnsRecordContext>()
        .Topic("inventory:adjust", topic => topic
            .Version<V1.InventoryAdjustment>("v1")
            .Version<V2.InventoryAdjustment>("v2")
            .Version<V3.InventoryAdjustment>("v3")
            // only the UPcasts; seed the field each version introduced
            .Upcast<V1.InventoryAdjustment, V2.InventoryAdjustment>(f => f.RegisterInitValue(o => o.WarehouseId, "wh-main"))
            .Upcast<V2.InventoryAdjustment, V3.InventoryAdjustment>(f => f.RegisterInitValue(o => o.Reason, "unspecified")))));

That one call:

Lower-level API (advanced / non-standard hosts)

AddPayloadVersioning wraps three primitives you can use directly if you need to: RegisterSchemaCastDefinitions (register each ISchemaCaster), RegisterPayloadSchemaVersions (build the ISchemaCasters aggregate), and UsePayloadVersionCasting<TContext>() (wrap the per-context mappers). Two caveats the one-call API handles for you: RegisterPayloadSchemaVersions expands the caster graph lazily, so a missing path throws on the first message rather than at startup; and UsePayloadVersionCasting<TContext>() must be the last registration of IRequestMapper<TContext>, so on a host whose transport registers that mapper with a last-wins AddScoped you must call it after the transport (e.g. via app.Register(...) in Configure). Prefer AddPayloadVersioning unless you have a reason not to.

The "default version" for a cast topic

With no benzene-version, the casting decorator delegates straight through: the body is deserialized directly as the handler's own (newest) type, no caster runs. So the handler's declared request type is the canonical/default version — ToSchemas names the version(s) the handler understands.

Testing it

Boot the real StartUp and set the version like any other header — a plain string:

var host = new AwsLambdaBenzeneTestHost(BenzeneTestHost.Create<StartUp>().BuildAwsLambdaHost());

var request = new BenzeneMessageRequest
{
    Topic = "inventory:adjust",
    Body = JsonConvert.SerializeObject(new V1.InventoryAdjustment { Sku = "ABC", Delta = 5 }),
    Headers = new Dictionary<string, string> { { "benzene-version", "v1" } }
};

var response = await host.SendEventAsync<BenzeneMessageResponse>(request);
// response.Body is v1-shaped (downcast); a value the v1→v2→v3 upcast seeded proves the chain ran.

On the client side, SendMessageAsync(topic, message, version: "v1") (or headers.WithVersion("v1")) sets the same benzene-version header for you. See examples/Versioning's tests for the full set: routing over the envelope/SQS/SNS, and chaining over the envelope/API Gateway/SQS, plus the single-hop, no-cast, and no-version-bypass cases.

Further reading