Levelwise
English
Messaging

Changing the message shape (Schema Evolution)

A message is a contract between teams. The sender and the receiver are never updated at the same time. So each change must be made in a way that both old code and new code understand the message.

Not reviewedWritten with AI helpReading time: 13 minExample of an online shopC# and .NET 10 code

Author: bezzad

The problem: one small change, four broken services

In our online shop, four services read the OrderCreated event: reports, SMS, warehouse and accounting. Each service has its own team and its own release time. Today the message looks like this:

{ "orderId": "o-1", "amount": 250000 }

The shop wants to support several currencies. The order team turns the amount into an object:

{ "orderId": "o-1", "amount": { "value": 250000, "currency": "IRR" } }
Order serviceNew code, released today{"orderId":"o-1","amount":{"value":250000,"currency":"IRR"}}The amount is now an object, not a numberReport serviceHas the new codeSMS serviceStill wants a numberJsonExceptionWarehouse serviceStill wants a numberJsonExceptionAccounting serviceStill wants a numberJsonException
Only the service that has the new code understands the message. The others fail on every message.

What happens, step by step?

  1. The SMS service still has the old code. In its code, the amount is a number.
  2. The new message arrives and the amount is an object. JSON conversion fails with an error.
  3. The consumer tries again and fails again. In Kafka, order matters, so the next messages also get stuck behind this message.
  4. The problem also happens the other way. If a service moves to the new code earlier, it does not understand the old messages that are still in the topic.
  5. Should we all release together? Making four teams release at the same moment is not possible in practice. Old messages also stay in the queue.

The main idea: a message is a contract

Think of a public API. You cannot change the type of a field one day and expect all clients to work correctly the same day. A message is exactly the same. Only harder, because:

  • A message stays. A message written today may be read again next week or next year.
  • The sender does not know the receivers. Maybe a service you do not know about also reads this message.

So the main rule is: old code and new code must work side by side for some time.

Two directions of compatibility

Backward compatibilityBACKWARDOld messagev1New codev2A consumer that updated earlyunderstands old messages in the queueForward compatibilityFORWARDNew messagev2Old codev1A consumer not updated yetunderstands new messagesBoth together: full compatibility
In a real system both cases happen. So usually both are needed.
  1. Backward compatibility (Backward). New code understands an old message. It is needed because old messages are still in the queue or topic.
  2. Forward compatibility (Forward). Old code understands a new message. It is needed because all consumers are not updated at the same time.
  3. Full compatibility (Full). Both together. The safest case for events that several teams read.

Which change is safe?

Change Is it safe? Why
Adding an optional field Yes Old code ignores the unknown field. New code sets a default value for an old message.
Adding a required field No Old messages do not have this field.
Removing a field No A consumer that still reads it gets an empty value.
Renaming a field No For old code, it means removing one field and adding another field.
Changing the type of a field No JSON conversion fails, like the example above.
Changing the meaning of a field, with the same name and type No, and the most dangerous It gives no error. Only the numbers become wrong. For example, the amount goes from rials to tomans.
Adding a new value to an enum Usually no Old code does not know the unknown value.

The right way: expand, migrate, contract

We split a big change into three small steps. Each step alone is safe.

1. ExpandNew field next to the old oneamount: 250000money: {...}The sender fills bothNobody breaks2. MigrateEach team, when it is readyReport: new fieldSMS: new fieldWarehouse: still old fieldTakes a few weeks3. ContractRemove the old fieldamountmoney: {...}When everyone has movedand old messages are not needed
At no moment does any consumer see a message it does not understand.
  1. Expand. Add a new field with a new name. The old field is still filled too.
  2. Migrate. Each team moves its code to reading the new field whenever it is ready.
  3. Contract. When everyone has moved and the old messages are not needed anymore, remove the old field.

The message in the first step:

{ "orderId": "o-1", "amount": 250000, "money": { "value": 250000, "currency": "IRR" } }

The sender side in C#:

public sealed record Money(decimal Value, string Currency);

public sealed record OrderCreated(
    string OrderId,
    [property: Obsolete("Use Money. Will be removed after all consumers move.")]
    decimal Amount,
    Money Money);

var message = new OrderCreated(order.Id, order.Total.Value, order.Total);
Another way: a new version of the event If the change is very big, make a new event, for example OrderCreatedV2. Publish both versions for some time. When all consumers have moved, stop the old version. The cost is that for some time you have two messages for one happening.

The receiver side: Tolerant Reader

The sender must be careful. But the receiver must also be tolerant:

  1. Read only the fields you need. The SMS service needs only the order id and the amount. Do not define the other fields in its class.
  2. Ignore unknown fields. The System.Text.Json library does this by default. Do not turn this behavior off.
  3. For a new field, accept that it may be missing. An old message does not have the new field. So define it as nullable and have a fallback.
// The SMS service reads only what it needs.
public sealed record OrderCreatedForSms(string OrderId, decimal? Amount, Money? Money)
{
    // Works with old messages (only Amount) and new ones (Money).
    public decimal Total => Money?.Value ?? Amount
        ?? throw new InvalidOperationException("Message has no amount.");
}
Two traps in System.Text.Json: First, if you define a property with the required keyword, a message that does not have that field is rejected with an error. So do not make the new field required in the consumer class. Second, if you send an enum as text and an unknown value arrives, JSON conversion gives an error. For enums that may grow, plan an “unknown” value or read it as plain text.

Reading old messages again (Replay)

A new service wants to read all orders of the past year from the start of Kafka. The messages of one year have several different shapes. We have two ways:

  1. The new service understands all shapes. This is possible for a few versions, but the code gets messy.
  2. A conversion layer (Upcaster). Before the message reaches the main code, turn each old version into the new version. The main code sees only one shape.

For this, each message must carry its version. For example in the message Header, or with a schema id.

public sealed record OrderCreatedV2(string OrderId, Money Money);

public static class OrderCreatedUpcaster
{
    public static OrderCreatedV2 Upcast(int version, JsonElement json) => version switch
    {
        1 => new OrderCreatedV2(
                json.GetProperty("orderId").GetString()!,
                new Money(json.GetProperty("amount").GetDecimal(), "IRR")), // v1 was always IRR
        2 => json.Deserialize<OrderCreatedV2>(JsonSerializerOptions.Web)!, // camelCase names
        _ => throw new NotSupportedException($"Unknown OrderCreated version {version}.")
    };
}
Remember: The time Kafka keeps messages (Retention) is limited. If the messages of one year were not kept, Replay is not possible.

Stopping it before Production with a Schema Registry

People forget the rules above. A tool can check them automatically.

  1. The message shape is defined in an exact language. Usually Avro or Protobuf, and sometimes JSON Schema.
  2. Each new version is registered in the Schema Registry. The sender registers the schema before sending.
  3. The compatibility rule is checked. If the new version does not match the rule (for example Backward), the registration is rejected. So the error is seen in CI or during release, not in Production.
  4. The message carries only the schema id. With this id, the receiver gets the exact message shape from the Registry.

Two points about the formats:

  • In Protobuf, each field has a number. Never use the number of a removed field again. Mark it as reserved.
  • In Avro, a new field must have a default value. Otherwise reading an old message with the new schema is not possible.

Common mistakes

Mistake Result The right way
Changing the type or name of a field in one step Old consumers fail and the queue gets stuck. Expand, migrate, contract.
“We will all release together” One team is late, and old messages are still in the queue. Old and new code side by side for some time.
Changing the meaning or unit of a field There is no error, but reports become wrong. A new field with a clear name, like currency.
A consumer class with all fields and required Every missing or extra field breaks the consumer. Only the needed fields, accepting that they may be missing.
A message without a version number During Replay, nobody knows what shape each message has. The version in the Header, or a schema id.
Removing the old field before the migration is finished One of the services silently gets an empty value. First make sure nobody reads it.

How strict do you need to be?

Fully strict

  • Several teams or several companies read the event.
  • Messages are kept for a long time and are replayed.
  • Using a Schema Registry and the Full rule is worth its cost.

Simpler is enough

  • The sender and the receiver are in one service and one release.
  • The queue keeps messages for only a few minutes.
  • The simple rules are enough: only add optional fields and be a tolerant reader.

Summary in six lines

  1. A message is a contract. The sender and the receiver are not updated at the same time.
  2. New code must understand the old message, and old code must understand the new message.
  3. Adding an optional field is safe. Removing, renaming and changing the type are not safe.
  4. Make a big change in three steps: expand, migrate, contract.
  5. The receiver reads only the needed fields and ignores unknown fields.
  6. Each message carries its version. A Schema Registry catches an incompatible change early.