Mert Özen Further With Every Line
Software Architecture Messaging and Event-Driven Architecture — #10

Message Contracts and Schema Evolution: CloudEvents, AsyncAPI, and Versioning

Mert Özen Nov 7, 2026 13 min 2 views
Message Contracts and Schema Evolution: CloudEvents, AsyncAPI, and Versioning

A message contract is a public API between independently deployed producers and consumers; adding a field is safe, while renaming a field or adding an enum value breaks consumers silently or loudly, so changes are made with expand-contract and versioned type names. Real breakage output with System.Text.Json, the CloudEvents 1.0 envelope with the C# SDK, an event catalog validated with AsyncAPI 3.1, and schema registry compatibility modes.

Version note: The code in this post was built and run with C# 14 and .NET 10; the CloudEvents example uses CloudNative.CloudEvents 2.9.0, and the AsyncAPI document was validated with @asyncapi/parser 3.6.6.

Guess First

The Ordering team is planning three changes to the OrderPlaced contract. One adds a currency field and a new shipping option; the other turns Total into TotalAmount "to make it clearer":

namespace Bookstore.Ordering.Contracts.V1
{
    public sealed record OrderPlaced(Guid OrderId, decimal Total, ShippingSpeed Shipping);

    public enum ShippingSpeed { Standard, Express }
}

namespace Bookstore.Ordering.Contracts.V2
{
    // Additions only: a new field and a new enum value
    public sealed record OrderPlaced(Guid OrderId, decimal Total, string Currency, ShippingSpeed Shipping);

    public enum ShippingSpeed { Standard, Express, SameDay }
}

namespace Bookstore.Ordering.Contracts.Renamed
{
    // A field renamed "to make it clearer"
    public sealed record OrderPlaced(Guid OrderId, decimal TotalAmount, V1.ShippingSpeed Shipping);
}

Inventory, Payments and Notification still run on V1, and all of them are deployed independently of Ordering. Which of the three changes does not affect the V1 consumer at all, which breaks it with a loud exception, and which produces wrong data without any error?

A Contract Is a Public API

Break a client of a synchronous HTTP (Hypertext Transfer Protocol) call and you see a 400 or a 500 right away. With messages the feedback comes much later, because producer and consumer are decoupled in both time and deployment. When the new version of Ordering goes live, thousands of messages in the old format may still be waiting in a queue; in Kafka, old events stay in the log for the whole retention period; the DLQ (dead-letter queue) may send a three-day-old message back for reprocessing. At the same time, old consumers will be reading new messages. So a message contract deserves as much care as a library's public API (Application Programming Interface), and compatibility is discussed in two directions. In the words of the Confluent Schema Registry documentation: backward compatibility means a consumer on the new schema can read data written with the old one; forward compatibility means a consumer on the old schema can read data written with the new one. Both together is full compatibility.

CompatibilityWho can read whatWho deploys firstSafe change
BackwardNew consumer, old dataConsumersRemove a field, add an optional field
ForwardOld consumer, new dataProducerAdd a field, remove an optional field
FullBothOrder does not matterOnly add or remove optional fields

In Bookstore, producers and consumers belong to different teams and deploy on different schedules; we do not want to coordinate which one goes first. So the practical goal is full compatibility: every change must be readable by both the old and the new side.

Adding Is Safe, Renaming Is Not

I tried the three changes with System.Text.Json, on JSON (JavaScript Object Notation) payloads with Web defaults (camelCase) and the string enum converter:

JsonSerializerOptions wire = new(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } };
Guid orderId = Guid.Parse("0199f0c2-7a10-7c3e-9d1b-2f4e6a8b0c11");

Console.WriteLine("1) v2 producer adds Currency; v1 consumer (default options)");
string v2Json = JsonSerializer.Serialize(new V2.OrderPlaced(orderId, 60m, "EUR", V2.ShippingSpeed.Express), wire);
Console.WriteLine($"  payload: {v2Json}");
Console.WriteLine($"  v1 reads: {JsonSerializer.Deserialize<V1.OrderPlaced>(v2Json, wire)}");

Console.WriteLine("2) Same payload, strict v1 consumer");
JsonSerializerOptions strict = new(wire) { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow };
Try(() => JsonSerializer.Deserialize<V1.OrderPlaced>(v2Json, strict));

Console.WriteLine("3) Producer renames Total to TotalAmount");
string renamedJson = JsonSerializer.Serialize(new Renamed.OrderPlaced(orderId, 60m, V1.ShippingSpeed.Standard), wire);
Console.WriteLine($"  payload: {renamedJson}");
Console.WriteLine($"  v1 reads: {JsonSerializer.Deserialize<V1.OrderPlaced>(renamedJson, wire)}");
JsonSerializerOptions required = new(wire) { RespectRequiredConstructorParameters = true };
Try(() => JsonSerializer.Deserialize<V1.OrderPlaced>(renamedJson, required));
1) v2 producer adds Currency; v1 consumer (default options)
  payload: {"orderId":"0199f0c2-7a10-7c3e-9d1b-2f4e6a8b0c11","total":60,"currency":"EUR","shipping":"Express"}
  v1 reads: OrderPlaced { OrderId = 0199f0c2-7a10-7c3e-9d1b-2f4e6a8b0c11, Total = 60, Shipping = Express }
2) Same payload, strict v1 consumer
  JsonException: The JSON property 'currency' could not be mapped to any .NET member contained in type 'Bookstore.Ordering.Contracts.V1.OrderPlaced'.
3) Producer renames Total to TotalAmount
  payload: {"orderId":"0199f0c2-7a10-7c3e-9d1b-2f4e6a8b0c11","totalAmount":60,"shipping":"Standard"}
  v1 reads: OrderPlaced { OrderId = 0199f0c2-7a10-7c3e-9d1b-2f4e6a8b0c11, Total = 0, Shipping = Standard }
  JsonException: JSON deserialization for type 'Bookstore.Ordering.Contracts.V1.OrderPlaced' was missing required properties including: 'total'.

Adding a field works fine with the default settings: System.Text.Json ignores the currency property it does not know. Martin Fowler described this attitude in 2011 as the Tolerant Reader: a consumer should read only the fields it needs and not care about the rest. But tolerance is a setting and is easily turned off; JsonUnmappedMemberHandling.Disallow, available since .NET 8, is a well-meant strictness switched on "so I notice anything unexpected right away", and it stops the whole consumer at the first additive change. In message consumers this setting should stay off.

The rename gave the worst result: no exception, no log, Total = 0. Not finding total, the V1 consumer passed decimal's default value to the positional record's parameter. Payments is about to capture zero euros for this order, and Notification is about to tell the customer "total: 0.00 EUR". The RespectRequiredConstructorParameters option that came with .NET 9 turns a missing constructor parameter into an exception; turning silent corruption into a loud failure is always better, because a loud failure ends up in the DLQ and can be fixed and reprocessed. Still, the real fix is not a serializer setting but making the change itself compatible: if a rename is truly needed, it is done with expand-contract (parallel change). First the new field is added next to the old one and both are filled, consumers move to the new field one by one, and finally the old field is removed. That takes three deployments instead of one, and because the producer needs to know who reads which field, the contract needs an owner.

Adding a Value to an Enum

We said adding is safe, but adding a value to an enum is an exception:

Console.WriteLine("4) v2 producer sends the new enum value SameDay");
string sameDay = JsonSerializer.Serialize(new V2.OrderPlaced(orderId, 60m, "EUR", V2.ShippingSpeed.SameDay), wire);
Try(() => JsonSerializer.Deserialize<V1.OrderPlaced>(sameDay, wire));
JsonSerializerOptions numeric = new(JsonSerializerDefaults.Web); // enums as numbers
V1.OrderPlaced? asNumber = JsonSerializer.Deserialize<V1.OrderPlaced>(
    JsonSerializer.Serialize(new V2.OrderPlaced(orderId, 60m, "EUR", V2.ShippingSpeed.SameDay), numeric), numeric);
Console.WriteLine($"  as a number: Shipping = {asNumber!.Shipping}, defined in v1: {Enum.IsDefined(asNumber.Shipping)}");
4) v2 producer sends the new enum value SameDay
  JsonException: The JSON value could not be converted to Bookstore.Ordering.Contracts.V1.OrderPlaced. Path: $.shipping | LineNumber: 0 | BytePositionInLine: 98.
  as a number: Shipping = 2, defined in v1: False

With the enum carried as a string, the V1 consumer does not know SameDay and rejects the whole message; every order with same-day shipping lands in Shipping's DLQ. Carried as a number it is worse: the consumer accepts 2 without question, but that value is not defined in V1 and falls into the default branch of every switch, perhaps into "treat it like Standard" logic. The first case at least gets noticed. The robust approach is to treat enums in a contract as open sets: the producer accepts up front that it may add values, the consumer that it may meet values it does not know. In practice a small custom converter on the consumer side that maps unknown values to an Unknown member, plus an explicit decision about what to do in that case (for example, "if you do not know same-day shipping, print a standard label but raise an alert"), is enough. The "consumers must accept unknown values" note in the AsyncAPI document below makes that rule part of the contract.

Versioned Type Names and Real Breaking Changes

Some changes cannot be made compatible at all: changing the type of OrderId, changing a field's meaning (Total switching from a VAT (value-added tax) inclusive amount to an exclusive one), splitting an event in two. A change of meaning is especially dangerous because no serializer can catch it: the field's name and type stay the same, only the value now says something else. Such a change is not a new version of the old contract but a new contract, published under a new name. That is what the bookstore.ordering.order-placed.v1 type name from the series' "Transactional Outbox and Inbox" post was for: when v2 ships, the producer publishes both v1 and v2 for a transition period (two outbox rows), consumers move to v2 on their own schedules, and v1 retires once nobody listens to it. Knowing who listens is the job of the AsyncAPI catalog and the broker's subscription list.

Sharing the Contract as a Schema, Not a DLL

The first reflex of .NET teams is to put the contracts into a Bookstore.Contracts NuGet package, a DLL (dynamic-link library) that everyone references. It is comfortable in the short run: type safety, IntelliSense, a single source. In the long run it brings a few problems. The package version turns into a deployment dependency; Inventory either does not compile without Ordering's new package or cannot see new fields with the old one. Over time the package accumulates helper types, extension methods and domain types that are "shared anyway"; that is why we limited the Shared Kernel to Money in the DDD (Domain-Driven Design) series' context map. And the contract only opens up to .NET; if a reporting service written in Python comes along later, the package tells it nothing.

The alternative is to publish the contract as a schema (JSON Schema, Avro or Protobuf) and a document, and to have each consumer write its own DTO (data transfer object) with only the fields it needs. Inventory does not need all of OrderPlaced, only OrderId and the books; the consumer's own DTO is the tolerant reader in code. The answer to the ownership question depends on the kind of message: an event is owned by whoever publishes it, the producer (in the DDD series' context map, Ordering publishes OrderPlaced as a Published Language); a command is owned by whoever handles it, the receiver (Payments defines the schema of CapturePayment). Testing in the producer's pipeline which fields consumers actually rely on, consumer-driven contract testing, is the subject of the "Service Contracts and Versioning" post in the upcoming Microservices series.

CloudEvents: A Shared Envelope

The payload schema varies from team to team, but the metadata every message carries (id, source, type, time) is the same. CloudEvents, a CNCF (Cloud Native Computing Foundation) project, defines a common format for that metadata; the project graduated in January 2024, the current release of the spec is 1.0.2, and the specversion value in an event is 1.0. There are four required attributes: id, source, specversion and type. The optional ones are datacontenttype, dataschema, subject and time. The spec's most important rule is that the pair of source and id must be unique for every distinct event; a re-sent event may carry the same id, and a consumer may treat events with the same source and id as duplicates. In other words, the dedup key from the series' "Idempotent Consumers" post has a standard name. The same OrderPlaced with the official C# SDK (Software Development Kit):

CloudEvent cloudEvent = new()
{
    Id = "0199f0c2-7a11-7d40-8c55-1a2b3c4d5e6f",
    Source = new Uri("https://example.com/bookstore/ordering"),
    Type = "com.example.bookstore.ordering.order-placed.v2",
    Subject = orderId.ToString(),
    Time = new DateTimeOffset(2026, 11, 8, 10, 0, 0, TimeSpan.Zero),
    DataContentType = MediaTypeNames.Application.Json,
    DataSchema = new Uri("https://example.com/bookstore/schemas/order-placed/v2.json"),
    Data = new V2.OrderPlaced(orderId, 60m, "EUR", V2.ShippingSpeed.Express)
};
JsonEventFormatter formatter = new(wire, new JsonDocumentOptions());
ReadOnlyMemory<byte> body = formatter.EncodeStructuredModeMessage(cloudEvent, out ContentType contentType);
Console.WriteLine($"  content type: {contentType}");
Console.WriteLine(Indent(Encoding.UTF8.GetString(body.Span)));

Console.WriteLine("6) A CloudEvent without source");
Try(() => formatter.EncodeStructuredModeMessage(new CloudEvent { Id = "1", Type = "com.example.test" }, out _));
5) The same event as a CloudEvent (structured mode)
  content type: application/cloudevents+json; charset=utf-8
{
  "specversion": "1.0",
  "id": "0199f0c2-7a11-7d40-8c55-1a2b3c4d5e6f",
  "source": "https://example.com/bookstore/ordering",
  "type": "com.example.bookstore.ordering.order-placed.v2",
  "subject": "0199f0c2-7a10-7c3e-9d1b-2f4e6a8b0c11",
  "time": "2026-11-08T10:00:00Z",
  "datacontenttype": "application/json",
  "dataschema": "https://example.com/bookstore/schemas/order-placed/v2.json",
  "data": {
    "orderId": "0199f0c2-7a10-7c3e-9d1b-2f4e6a8b0c11",
    "total": 60,
    "currency": "EUR",
    "shipping": "Express"
  }
}
6) A CloudEvent without source
  ArgumentException: CloudEvent is missing required attributes: source (Parameter 'cloudEvent')

The spec recommends that type start with the reversed domain name (reverse-DNS) of the organization that defines its rules; to avoid inventing a real domain for Bookstore I used example.com in the examples. The version sits at the end of the type name, the schema's address in dataschema. In the structured mode used here, envelope and payload form a single JSON document; in binary mode the attributes move into the transport's headers (ce-id, ce-type and so on in HTTP) and the body is only the payload. Because binary mode lets old consumers that know nothing about CloudEvents keep reading the payload as before, it is the gentler path for adding it to an existing system later. Bookstore's own envelope (MessageId, Type, CorrelationId, OccurredAt) maps almost one-to-one onto CloudEvents; information the spec does not define, such as the correlation id, is added as an extension attribute. Making the envelope CloudEvents from the start makes it cheaper to connect later to an event gateway, to a service in another language, or to a cloud provider's event service.

An Event Catalog with AsyncAPI

AsyncAPI does for message-based APIs what OpenAPI does for HTTP APIs: it collects which message goes to which channel, along with the payload schema, in a machine-readable document. 3.1.0, released in January 2026, is a minor release with no breaking changes. The document Ordering publishes for OrderPlaced:

asyncapi: 3.1.0
info:
  title: Bookstore Ordering events
  version: 2.0.0
defaultContentType: application/json
channels:
  orderPlaced:
    address: bookstore.ordering.order-placed
    messages:
      orderPlacedV2:
        $ref: '#/components/messages/orderPlacedV2'
operations:
  publishOrderPlaced:
    action: send
    channel:
      $ref: '#/channels/orderPlaced'
    messages:
      - $ref: '#/channels/orderPlaced/messages/orderPlacedV2'
components:
  messages:
    orderPlacedV2:
      name: com.example.bookstore.ordering.order-placed.v2
      title: An order was placed
      payload:
        $ref: '#/components/schemas/orderPlacedV2'
  schemas:
    orderPlacedV2:
      type: object
      required: [orderId, total, currency, shipping]
      properties:
        orderId:
          type: string
          format: uuid
        total:
          type: number
        currency:
          type: string
          description: ISO 4217 code, added in v2
        shipping:
          type: string
          description: Standard, Express, SameDay; consumers must accept unknown values
valid AsyncAPI 3.1.0, operations: publishOrderPlaced:send

In AsyncAPI 3, action is written from the application's point of view: send means this application sends messages to the channel, receive means it receives messages from it. Inventory writes receive for the same channel in its own document; put the two documents side by side and the answer to "who publishes this event and who listens to it", an event catalog, emerges. I validated the document with AsyncAPI's JavaScript parser; when I added an undefined field under info, the same parser rejected it with "Property is not expected to be here". Putting this validation into the CI (continuous integration) pipeline keeps the document from drifting away from the code.

One step further is a schema registry: a central service where schemas are versioned and every new version is checked against a compatibility rule. In Confluent Schema Registry the default rule is backward, checked only against the latest version; the transitive types check against all earlier versions. When a producer tries to publish with a new schema, the incompatibility is caught at schema registration rather than at deploy time. It is a natural choice for teams using Kafka with Avro or Protobuf; for Bookstore, with a few teams on RabbitMQ and JSON, validating the AsyncAPI document in CI and writing a compatibility test is enough today. Serializer choice and Avro/Protobuf details are covered in the Backend series' "Message Contracts" post.

Common Pitfalls

Using the domain event as the integration contract as is

We saw it in the table in the DDD series' "Domain Events" post: a domain event changes freely with the model, an integration event is a versioned contract. Serializing a domain event and publishing it directly turns every refactoring of the internal model into another team's production incident. There should always be an explicit mapping in between.

Renaming or removing a field

The output above showed it: a rename produces wrong data without an error. It must be done with expand-contract. Removing a field is the same; before removing it, make sure nobody reads it.

Treating an enum as a closed set

A producer adding new values is natural evolution. A consumer that does not recognize an unknown value either rejects the whole message or falls back to a wrong default. Both must be agreed in the contract beforehand.

Making every field required

If every field in the contract is required, making a field optional or removing it becomes a breaking change. Keep "required" for fields the consumer truly cannot work without.

Not counting a change of meaning as a version

Changing the meaning of a field with the same name and type is the one change no schema tool can catch. If the meaning changes, you need a new field or a new type name.

When Not to Use It

CloudEvents, AsyncAPI and a schema registry are each a separate discipline with a cost. If in-memory events travel between modules of a modular monolith inside one process, the contracts compile in the same solution and the compiler already catches incompatibilities; versions and catalogs are unnecessary. If producer and consumer belong to the same team and deploy together, a light tolerant reader discipline may be enough. These tools gain value as the number of teams grows and nobody can keep in their head who listens to which event; Bookstore's plan to grow to four teams in 12 to 18 months is exactly that threshold.

A Small Experiment

★☆☆ Write a small JsonConverter that maps an unknown string value of V1.ShippingSpeed to a new member named Unknown, and run scenario 4 again. What does the consumer see now?

★★☆ Plan the Total → TotalAmount change with expand-contract: at each step, what does the producer publish and what does the consumer read? Write the JSON of the intermediate step and verify that the V1 consumer still reads the right Total.

★★★ Write a second AsyncAPI document for Inventory with action: receive, and a small script that compares the two documents and lists messages that are "published but nobody listens" and "listened to but nobody publishes". What information did you have to add to the documents?

Contracts now change in a controlled way. What remains is the question we have deferred since the start of the series: when an order gets stuck somewhere in all this asynchronous flow, how do we answer "what happened?", and which flows should never be event-driven at all? In the last post of the series we look at the cost of event-driven architecture, observability, and Bookstore's final architecture.