Mert Özen Further With Every Line
Backend Working with Message Queues — #06

Message Contracts: Serialization, Versioning and Backward Compatibility

Mert Özen Nov 8, 2026 10 min 5 views
Message Contracts: Serialization, Versioning and Backward Compatibility

Keep contracts as records separate from domain entities, evolve them only by adding optional fields, turn on RespectRequiredConstructorParameters and RespectNullableAnnotations in System.Text.Json, and carry the version in a type header. Real output: with default settings a rename becomes a 0 TL invoice, a new enum value breaks old consumers, and Schema Registry rejects an incompatible schema with 409.

Version note: The code in this post was built and run with .NET 10, System.Text.Json 10, Confluent.SchemaRegistry 2.16.0 and Confluent Schema Registry 8.1.0.

Guess First

The Order API (Application Programming Interface) team renamed the Total field in OrderPlaced to TotalAmount to make it more descriptive, and deployed. Billing is still on the old version and reads the message with System.Text.Json's JsonSerializerDefaults.Web settings. What does Billing see: a JsonException, or something else? For what amount is the invoice issued?

Second question: a new value, SameDay, was added to the ShippingMethod enum, and the new producer started sending it. Can an old consumer that serializes enums as strings read this message?

What Is a Contract, and Where Does It Live?

A message contract is the only shared language between producer and consumer: the message's name, fields, types and meaning. Bookstore has a small assembly called Bookstore.Contracts that contains nothing but records:

namespace Bookstore.Contracts;

public sealed record OrderPlaced(
    Guid OrderId,
    Guid CustomerId,
    decimal Total,
    string Currency,
    DateTimeOffset PlacedAt,
    IReadOnlyList<OrderPlacedItem> Items)
{
    public const string MessageType = "bookstore.order-placed.v1";
}

public sealed record OrderPlacedItem(Guid BookId, int Quantity, decimal UnitPrice);

Sharing this assembly (as a NuGet package or a project reference in the same repository) cuts both ways. The upside is the compiler's help: a field name cannot be misspelled, and refactorings spread everywhere. The downside is the same thing: a "rename" refactoring in the Order API breaks Billing's code before it even compiles, and a shared package locks services to the same version, which makes independent deployment harder. The other extreme is not sharing the contract at all: each consumer defines only the fields it needs in its own record. This "tolerant reader" approach is the way that decouples services the most. A practical middle ground: a shared contract package between services of the same team, and an unshared contract documented with a schema (JSON Schema, Avro, AsyncAPI) at the boundary between teams or companies. The conceptual side is in the Messaging and Event-Driven Architecture series post "Message Contracts and Schema Evolution" and the Microservices series post "Service Contracts and Versioning"; here we look at how to apply it in .NET code.

Whatever you choose, do not break one rule: do not publish a domain entity as a message. If EF (Entity Framework) Core's Order entity goes into the message with its navigation properties, internal state and fields tied to the database schema, every change you make to the database becomes a contract change, and consumers learn your internal model. OrderPlaced is a separate record and uses OrderPlacedItem instead of the OrderItem entity; that is deliberate.

Making System.Text.Json Settings Part of the Contract

Serialization settings are part of the contract too. If the producer writes camelCase and the consumer expects PascalCase, fields silently stay empty. We keep the settings in one place, in the contract assembly:

public static class MessageJson
{
    public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
    {
        Converters = { new JsonStringEnumConverter() },
        RespectRequiredConstructorParameters = true,
        RespectNullableAnnotations = true
    };
}

[JsonSourceGenerationOptions(JsonSerializerDefaults.Web,
    UseStringEnumConverter = true,
    RespectRequiredConstructorParameters = true,
    RespectNullableAnnotations = true)]
[JsonSerializable(typeof(V1.OrderPlaced))]
[JsonSerializable(typeof(V2.OrderPlaced), TypeInfoPropertyName = "OrderPlaced2")]
[JsonSerializable(typeof(V1.OrderShipped))]
public sealed partial class ContractsJsonContext : JsonSerializerContext;

Each setting is a decision:

  • JsonSerializerDefaults.Web: camelCase property names and case-insensitive reading. If a service written in Java or Node.js will also read the message, camelCase is the least surprising choice.
  • JsonStringEnumConverter: enums are written as names, not numbers. "Express" instead of 2 is both readable and does not shift meaning when the enum's order changes.
  • RespectRequiredConstructorParameters and RespectNullableAnnotations: these two settings, introduced in .NET 9, turn missing required fields and null arriving in non-nullable fields into exceptions. We will see shortly why they are vital.
  • Source generation (JsonSerializerContext): a serializer generated at compile time instead of reflection. It removes the reflection cost on every message and is compatible with trimming and Native AOT (Ahead-of-Time).

OrderPlaced serialized with the source generator:

{"orderId":"01a1b8de-6a5a-70fd-8aab-7f9dd66cdbe7","customerId":"3f2b8c1e-7d4a-4e9b-9c55-1a2b3c4d5e6f","total":1220.50,"currency":"TRY","placedAt":"2026-11-08T01:55:45+00:00","items":[{"bookId":"0b9d6a4e-1c2f-4a8b-b3d7-5e6f7a8b9c0d","quantity":2,"unitPrice":450.00}]}

Outside the body, every message carries the same three pieces of metadata: MessageId (assigned once per message, unchanged on redelivery), CorrelationId (to follow the order or the request) and type (the contract name and version, like bookstore.order-placed.v1). In RabbitMQ these are the MessageId, CorrelationId and Type fields of BasicProperties; in Azure Service Bus the MessageId and CorrelationId properties of ServiceBusMessage and ApplicationProperties["type"]; in Kafka, message headers. Putting the type in the metadata rather than the body lets the consumer pick the right deserializer without opening the body.

An Additive Change: OrderPlaced v1 to v2

The Notification service needs the customer's e-mail address to send the order confirmation e-mail; today it finds it by asking the Customer API on every message. We want to add the e-mail to OrderPlaced. To run both versions side by side in the experiment, we put them in V1 and V2 namespaces; in a real repository there is a single OrderPlaced, and these represent two different deployments:

// v2: only an optional field appended at the end
public sealed record OrderPlaced(
    Guid OrderId,
    Guid CustomerId,
    decimal Total,
    string Currency,
    DateTimeOffset PlacedAt,
    IReadOnlyList<OrderPlacedItem> Items,
    string? CustomerEmail = null);

Can the old consumer read the new message, and the new consumer the old one?

v1 reads v2: 1220.50
v2 reads v1: CustomerEmail=null

Both work. System.Text.Json ignores unknown fields by default; the old consumer ignored customerEmail. Because the new field is nullable and has a default value, the new consumer did not treat its absence in the old message as an error. The rules of an additive change are short: the new field is optional (nullable or with a default), the names, types and meanings of existing fields do not change, and a required field is never removed. The rule on the consumer side: know what you will do when the new field does not arrive. If CustomerEmail is null, Notification keeps asking the Customer API as before.

Breaking Changes: Rename, Null and Enum

The first question at the top. The producer now writes totalAmount, the old consumer expects total:

{"orderId":"01a1b8de-6a5a-70fd-8aab-7f9dd66cdbe7","customerId":"3f2b8c1e-7d4a-4e9b-9c55-1a2b3c4d5e6f","totalAmount":1220...
v1 reads renamed (default options): Total=0
v1 reads renamed (strict options): JsonException: JSON deserialization for type 'Bookstore.Contracts.V1.OrderPlaced' was missing required properties including: 'total'.

With the default settings there is no exception. The missing constructor parameter gets its type's default value, and Billing issues a 0 TL invoice for a 1,220.50 TL order. This is the most expensive kind of error in messaging: silent data corruption. With RespectRequiredConstructorParameters = true the same message throws a JsonException. An exception is annoying, but visible; the message lands in the DLQ (Dead Letter Queue), an alarm goes off, and nobody gets a wrong invoice.

The same logic applies to null. When a bug in the producer sends currency as null:

v1 reads null currency (default options): Currency=null
v1 reads null currency (strict options): JsonException: The constructor parameter 'Currency' on type 'Bookstore.Contracts.V1.OrderPlaced' doesn't allow null values. Consider updating its nullability annotation. Path: $.currency | LineNumber: 0 | BytePositionInLine: 133.

With the default settings, null goes into a string Currency (non-nullable) property, and a NullReferenceException blows up much later, on an unrelated line. RespectNullableAnnotations catches the error at the message boundary.

The second question, a new enum value:

{"orderId":"01a1b8de-6a5a-70fd-8aab-7f9dd66cdbe7","method":"Express"}
v1 reads new enum value: JsonException: The JSON value could not be converted to Bookstore.Contracts.V1.OrderShipped. Path: $.method | LineNumber: 0 | BytePositionInLine: 68.

Adding a value to an enum looks innocent from the producer's side but is a breaking change for old consumers. There are three options: update all consumers before sending the new value; use a string instead of an enum in the contract and let the consumer handle unknown values with its own logic; or write a custom converter that falls back to an Unknown value. Whichever you choose, make the decision while designing the contract.

If you really need a breaking change (a field's name, type or meaning changes), do not change the message in place. The expand-contract sequence works like this: first the producer writes both fields for a while (total and totalAmount), or publishes a second message with a new type; consumers move to the new field one by one; once you are sure no consumer reads the old field, the producer drops it. Do not forget the old messages waiting in the queue or the Kafka log: old-version messages keep being read for the whole retention period.

Choosing the Version by Type Header, and Upcasting

Instead of forking the consumer's code per version, it is cleaner to read at the boundary and work with a single model inside:

public static class OrderPlacedReader
{
    // read the right version by the type header, so the consumer always works with the newest model inside
    public static V2.OrderPlaced Read(string type, ReadOnlySpan<byte> body) => type switch
    {
        "bookstore.order-placed.v2" => JsonSerializer.Deserialize(body, ContractsJsonContext.Default.OrderPlaced2)!,
        "bookstore.order-placed.v1" => Upcast(JsonSerializer.Deserialize(body, ContractsJsonContext.Default.OrderPlaced)!),
        _ => throw new NotSupportedException($"Unknown message type '{type}'")
    };

    private static V2.OrderPlaced Upcast(V1.OrderPlaced m) =>
        new(m.OrderId, m.CustomerId, m.Total, m.Currency, m.PlacedAt, m.Items, CustomerEmail: null);
}
bookstore.order-placed.v1: total=1220.50 email=null
bookstore.order-placed.v2: total=1220.50 email=ada@example.com
unknown type: NotSupportedException: Unknown message type 'bookstore.order-placed.v3'

Upcasting means converting an old-version message to the new model at read time; the same technique is used for event versioning in Event Sourcing. Throwing on an unknown type is deliberate: instead of guessing, let the message land in the DLQ and deploy a consumer that supports the new version. For additive changes you do not have to bump the type; a v1 consumer can already read a v2 message. Save the version number for breaking changes where an old consumer would misread the new message.

Schema Registry and Compatibility Modes in Kafka

With JSON (JavaScript Object Notation), the contract rules depend on people's discipline. In the Kafka ecosystem, Schema Registry hands that discipline to a machine: before writing a message, the producer registers the schema with the registry, the registry checks whether the new schema is compatible with the previous ones and rejects an incompatible schema. Not the schema itself but a schema id of a few bytes is added to the message. Avro, Protobuf and JSON Schema are supported. The Avro schema (v1) for Bookstore's OrderPlaced:

{
  "type": "record", "name": "OrderPlaced", "namespace": "Bookstore.Contracts",
  "fields": [
    { "name": "orderId", "type": "string" },
    { "name": "customerId", "type": "string" },
    { "name": "total", "type": { "type": "bytes", "logicalType": "decimal", "precision": 18, "scale": 2 } },
    { "name": "currency", "type": "string" }
  ]
}

For a money amount, not double but Avro's decimal logical type: that is the counterpart of C#'s decimal. We check compatibility from .NET with the Confluent.SchemaRegistry package:

using CachedSchemaRegistryClient registry = new(new SchemaRegistryConfig { Url = "http://localhost:17240" });
string subject = OrderPlacedSchemas.Subject;

int id = await registry.RegisterSchemaAsync(subject, new Schema(OrderPlacedSchemas.V1, SchemaType.Avro));
Console.WriteLine($"v1 registered with id {id}");

foreach ((string name, string schema) in new[]
{
    ("v2 with default", OrderPlacedSchemas.V2WithDefault),
    ("v2 without default", OrderPlacedSchemas.V2WithoutDefault),
    ("renamed field", OrderPlacedSchemas.Renamed)
})
{
    bool ok = await registry.IsCompatibleAsync(subject, new Schema(schema, SchemaType.Avro));
    Console.WriteLine($"{name,-20} compatible: {ok}");
}
v1 registered with id 3
v2 with default      compatible: True
v2 without default   compatible: False
renamed field        compatible: False
SchemaRegistryException (409): Schema being registered is incompatible with an earlier schema for subject "bookstore.orders-value", details: [{errorType:'READER_FIELD_MISSING_DEFAULT_VALUE', description:'The field 'totalAmount' at path '/fields/2' in the new schema has no default value and
v2 registered with id 4; versions: 1, 2

The registry's default mode is BACKWARD (GET /config returned {"compatibilityLevel":"BACKWARD"}): the new schema must be able to read data written with the last schema. customerEmail added with "default": null passes this rule; the field added without a default and the renamed field do not, because someone reading with the new schema will not find that field in an old message. When we tried to register the rename, the registry rejected it with 409 and stated the reason. So a bad contract change is caught in the producer's deploy pipeline, without breaking Billing in production.

The other modes: FORWARD (the old schema must be able to read new data; producers are upgraded first), FULL (both), their _TRANSITIVE variants that compare against the whole history rather than just the last version, and NONE. You can set the mode per subject. BACKWARD means "consumers are upgraded first"; set up your deploy order accordingly.

Production Notes

Treat contract changes as a separate category in code review. A diff in Bookstore.Contracts changes the agreement between two services; ask "can the old consumer read this?" in the review. Automating that with contract tests is the subject of the series' testing post.

Consider logging unknown fields. System.Text.Json's JsonUnmappedMemberHandling.Disallow setting throws on an unknown field; turning it on in a consumer breaks the tolerant reader, but turning it on in a test environment shows the gap between "what the producer sends and what I read" early.

Watch message size. As the contract grows, the message grows; Azure Service Bus Standard has a 256 KB limit. Carry large data by reference, not in the message.

Add personal data consciously. CustomerEmail is now inside every OrderPlaced and goes wherever the message goes: in the Kafka log for the retention period, in the DLQ, in logs. For KVKK (Kişisel Verilerin Korunması Kanunu, Turkey's personal data protection law), document which personal data travels in the message.

Common Pitfalls

Publishing a domain entity as a message. Every change to the internal model becomes a contract change.

Reading contracts with the default System.Text.Json settings. A rename silently becomes a 0 TL invoice, and a null becomes a NullReferenceException far away.

Thinking that adding an enum value is additive. Old consumers throw.

Different serialization settings in the producer and the consumer. One writes PascalCase, the other reads camelCase; case-insensitive reading sometimes hides this, and sometimes does not.

Making a breaking change in a single deploy. The old messages waiting in the queue and the consumers not yet upgraded break at the same time.

Running Schema Registry in NONE mode. You turn off the very check the registry exists for.

A Small Experiment

★☆☆ Add an optional string? Title field to OrderPlacedItem and verify that the v1 consumer can still read the new message.

★★☆ Write a JsonConverter<ShippingMethod> for ShippingMethod that maps unknown values to Unknown. Show that a message containing SameDay no longer throws.

★★★ Switch the subject's mode to FULL_TRANSITIVE (PUT /config/bookstore.orders-value) and try which changes are still accepted across three schema versions. Compare the results with BACKWARD in a table.

So far we have used each broker's client directly: we managed connections, channels, offsets, locks and contracts ourselves. In the next post we hand part of that work to an abstraction: consumers, publish and send with MassTransit, broker topology conventions, the same code running on RabbitMQ and Azure Service Bus, and the licensing reality of MassTransit v9.