Changing a message without breaking its consumers

ยท 2 min read

Once other services consume your messages, the message format is a public contract. Additive changes are easy; breaking ones need a plan. Here's how to evolve message contracts safely.

With an HTTP API you can see who's calling you. With messages, you often can't. Any number of subscribers may be reading OrderPlaced, some written by other teams, some deployed months ago. And messages sitting in a queue or dead-letter queue were written by an older version of your code. A change that breaks deserialization breaks all of them.

Safe changes: add, don't change

Adding an optional field is safe:

// Before
public record OrderPlaced(Guid OrderId, string CustomerId, decimal Total);

// After
public record OrderPlaced(Guid OrderId, string CustomerId, decimal Total, string? Currency = null);

Old consumers don't know about Currency and ignore it: System.Text.Json skips unknown properties by default. New consumers reading old messages get null and must handle that, for example by assuming the currency the old system always used.

Breaking changes

These break consumers:

  • Renaming or removing a field
  • Changing a field's type or meaning, such as Total switching from including tax to excluding it
  • Making an optional field required

Changing meaning is the most dangerous, because nothing fails. Every consumer keeps working, with wrong numbers.

Making a breaking change safely

Introduce a new message type and run both for a while:

  1. Define OrderPlacedV2 with the new shape.
  2. Update the producer to publish both OrderPlaced and OrderPlacedV2 for every order.
  3. Each consumer team moves to V2 at its own pace.
  4. When no subscriber needs V1 any more, stop publishing it.

Put the type and version where consumers and subscription filters can see them without reading the body:

var message = new ServiceBusMessage(BinaryData.FromObjectAsJson(v2))
{
    Subject = "OrderPlaced",
    MessageId = $"order-placed-v2-{v2.OrderId}"
};
message.ApplicationProperties["MessageType"] = "OrderPlaced";
message.ApplicationProperties["SchemaVersion"] = 2;

Habits that make this easier

  • Be a tolerant reader. Consumers should read only the fields they need and ignore everything else.
  • Don't send internal types. Messages built from your entities change whenever the entity does. Define message contracts separately and deliberately.
  • Include identifiers and enough context. Consumers that have to call back to you for basic details are coupled to your API as well as your messages.
  • Keep contracts in one place, such as a shared contracts package or a schema registry, so changes are visible and reviewed.
  • Deploy consumers before producers. When you add a field that consumers must handle, update them first so they're ready when it starts arriving.

Takeaway

Treat message formats as public contracts. Make additive, optional changes freely, never change a field's meaning silently, and introduce a new versioned message type for breaking changes, publishing both until every consumer has moved.