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.
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" } }
What happens, step by step?
- The SMS service still has the old code. In its code, the amount is a number.
- The new message arrives and the amount is an object. JSON conversion fails with an error.
- The consumer tries again and fails again. In Kafka, order matters, so the next messages also get stuck behind this message.
- 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.
- 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 compatibility (Backward). New code understands an old message. It is needed because old messages are still in the queue or topic.
- Forward compatibility (Forward). Old code understands a new message. It is needed because all consumers are not updated at the same time.
- 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.
- Expand. Add a new field with a new name. The old field is still filled too.
- Migrate. Each team moves its code to reading the new field whenever it is ready.
- 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);
The receiver side: Tolerant Reader
The sender must be careful. But the receiver must also be tolerant:
- 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.
- Ignore unknown fields. The System.Text.Json library does this by default. Do not turn this behavior off.
- 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.");
}
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:
- The new service understands all shapes. This is possible for a few versions, but the code gets messy.
- 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}.")
};
}
Stopping it before Production with a Schema Registry
People forget the rules above. A tool can check them automatically.
- The message shape is defined in an exact language. Usually Avro or Protobuf, and sometimes JSON Schema.
- Each new version is registered in the Schema Registry. The sender registers the schema before sending.
- 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.
- 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
- A message is a contract. The sender and the receiver are not updated at the same time.
- New code must understand the old message, and old code must understand the new message.
- Adding an optional field is safe. Removing, renaming and changing the type are not safe.
- Make a big change in three steps: expand, migrate, contract.
- The receiver reads only the needed fields and ignores unknown fields.
- Each message carries its version. A Schema Registry catches an incompatible change early.