Event-driven applications allow different services to communicate without depending on each other's internal implementation. A service publishes an event, and other services consume it to perform their own operations.
For example, an order service may publish an OrderCreated event. The inventory service reserves stock, the payment service processes the payment, and the notification service sends an order confirmation.
This architecture works well until the event structure changes.
Suppose the order service originally publishes a customer name and order amount. Later, the business requires a customer ID, currency, and delivery information. If the event schema changes without considering existing consumers, some applications may stop processing messages.
Schema evolution is the process of changing event structures while maintaining compatibility with existing producers and consumers.
This article explains how to update event schemas safely using C#, ASP.NET Core, and RabbitMQ-style messaging patterns.
Why Event Schema Changes Cause Problems
Consider an application that publishes this event:
public class OrderCreated
{
public int OrderId { get; set; }
public string CustomerName { get; set; }
= string.Empty;
public decimal Amount { get; set; }
}
The order service publishes the event, and several services consume it.
After a few months, the business decides to replace customer names with customer IDs and include delivery information.
The updated event might look like this:
public class OrderCreated
{
public int OrderId { get; set; }
public int CustomerId { get; set; }
public decimal Amount { get; set; }
public string Currency { get; set; }
= "INR";
public string DeliveryAddress { get; set; }
= string.Empty;
}
The new event contains different fields. Existing consumers may still expect CustomerName.
This creates several compatibility problems:
Old consumers may fail when a required field disappears.
New consumers may receive messages without newly required fields.
Different services may deploy at different times.
Messages already stored in the queue may use an older schema.
Historical events may need to be replayed after a deployment.
The main challenge is that event producers and consumers do not necessarily run the same application version.
Understand Schema Compatibility
Before changing an event, determine how the change affects existing producers and consumers.
There are three common compatibility models.
Backward compatibility
A new consumer can process events produced using an older schema.
For example, a new consumer expects a Currency property, but older messages do not contain it.
The consumer can use a default value when the property is missing.
Forward compatibility
An older consumer can process events produced using a newer schema.
For example, a newer event includes a DeliveryAddress property. An older consumer ignores the additional property because it does not use it.
This works when the serialization format and deserialization configuration support unknown fields.
Full compatibility
A change supports both backward and forward compatibility.
This is useful when producers and consumers are deployed independently and messages from different versions may exist at the same time.
Not every change can maintain full compatibility. Removing a required field or changing its meaning usually needs a migration strategy.
Use Explicit Event Contracts
Avoid using database entities directly as event messages.
Database entities describe how data is stored. Event contracts describe information exchanged between services.
These responsibilities are different.
For example, an order database entity might contain internal properties such as:
Database-generated identifiers.
Navigation properties.
Internal processing flags.
Audit timestamps.
Payment provider references.
An external consumer should not automatically receive all these properties.
Create a separate event contract:
public class OrderCreatedV1
{
public int OrderId { get; set; }
public string CustomerName { get; set; }
= string.Empty;
public decimal Amount { get; set; }
}
A versioned event contract makes the published structure explicit.
The order service can map its internal entity to this contract before publishing the message.
This also prevents unrelated database changes from accidentally modifying an event schema.
Add Optional Fields Instead of Replacing Existing Fields
One of the safest ways to evolve an event is to add new optional properties.
Suppose the business needs to include a customer ID.
Instead of immediately removing CustomerName, add CustomerId:
public class OrderCreated
{
public int OrderId { get; set; }
public string CustomerName { get; set; }
= string.Empty;
public decimal Amount { get; set; }
public int? CustomerId { get; set; }
}
Older consumers can continue reading the fields they already understand.
New consumers can use the additional property when it is available.
The nullable type is important because older messages may not contain a customer ID.
When using JSON serialization, configure your contracts and deserialization behavior deliberately. A missing property should not be confused with an explicitly supplied default value when that distinction matters.
Also, do not assume that adding a field is always harmless. Some serializers, schema validators, or generated clients may reject unknown properties or require strict schema validation.
Use Event Versioning for Breaking Changes
Sometimes a change cannot be introduced safely through optional fields.
For example, changing Amount from a decimal value to a complex object changes the meaning and structure of the event.
Instead of modifying the existing contract immediately, introduce a new version.
public class OrderCreatedV1
{
public int OrderId { get; set; }
public decimal Amount { get; set; }
}
The new version can represent the amount with additional information:
public class OrderCreatedV2
{
public int OrderId { get; set; }
public Money Amount { get; set; }
}
public class Money
{
public decimal Value { get; set; }
public string Currency { get; set; }
= "INR";
}
Now the application has two explicit contracts.
A message envelope can identify the event type and version:
public class EventEnvelope<T>
{
public Guid EventId { get; set; }
public string EventType { get; set; }
= string.Empty;
public int SchemaVersion { get; set; }
public DateTime OccurredAtUtc { get; set; }
public T Data { get; set; } = default!;
}
A publisher can create a versioned event:
var message = new EventEnvelope<OrderCreatedV2>
{
EventId = Guid.NewGuid(),
EventType = "OrderCreated",
SchemaVersion = 2,
OccurredAtUtc = DateTime.UtcNow,
Data = new OrderCreatedV2
{
OrderId = 4521,
Amount = new Money
{
Value = 1500,
Currency = "INR"
}
}
};
The version should be part of the message contract, not inferred from the deployment version of the application.
This allows consumers to decide how to process different event versions.
Handle Multiple Versions in Consumers
During a migration, a consumer may need to process both V1 and V2 messages.
A simple approach is to inspect the schema version and deserialize the message into the appropriate contract.
public async Task HandleAsync(
string payload,
int schemaVersion,
CancellationToken cancellationToken)
{
switch (schemaVersion)
{
case 1:
var v1 = JsonSerializer.Deserialize<OrderCreatedV1>(
payload);
if (v1 == null)
{
throw new InvalidOperationException(
"Invalid V1 event.");
}
await HandleV1Async(
v1,
cancellationToken);
break;
case 2:
var v2 = JsonSerializer.Deserialize<OrderCreatedV2>(
payload);
if (v2 == null)
{
throw new InvalidOperationException(
"Invalid V2 event.");
}
await HandleV2Async(
v2,
cancellationToken);
break;
default:
throw new NotSupportedException(
$"Unsupported schema version: {schemaVersion}");
}
}
The example assumes that the schema version is available in message metadata or an envelope that has already been parsed.
Avoid silently interpreting an unknown version as the latest version. A consumer should reject unsupported contracts in a controlled way and route the message for investigation rather than processing it incorrectly.
For RabbitMQ, include event metadata in the message headers or a consistent message envelope. Make sure the consumer uses the same versioning convention as the publisher.
Follow a Safe Event Migration Strategy
Changing a production event should be a planned migration rather than a single deployment.
A practical approach is to use an expand-and-contract process.
Step 1: Expand the event contract
Add the new fields or introduce the new event version without removing the old contract.
For example, publish the new customer ID while keeping the existing customer name field.
Step 2: Update consumers
Deploy consumers that understand both the old and new event formats.
Test them with messages from both versions.
Step 3: Update the producer
Change the producer to publish the new event format.
If multiple consumers depend on the old event, keep the old format available during the migration.
Step 4: Monitor consumer adoption
Check whether all consumers have successfully processed the new version.
Review processing failures, dead-letter queues, and application logs.
Step 5: Remove the old contract
Only remove the old version after confirming that no active consumers require it and no required historical messages remain unprocessed.
This process reduces the risk of deploying a producer change before consumers are ready.
Validate Event Schemas Before Publishing
A message can be valid JSON and still violate the expected event contract.
For example, an order event may contain an amount of zero when the business requires a positive amount.
Validation should happen before publishing the event.
A simple validation method might look like this:
public static void Validate(OrderCreatedV2 order)
{
if (order.OrderId <= 0)
{
throw new ArgumentException(
"Order ID must be greater than zero.");
}
if (order.Amount.Value <= 0)
{
throw new ArgumentException(
"Order amount must be greater than zero.");
}
if (string.IsNullOrWhiteSpace(
order.Amount.Currency))
{
throw new ArgumentException(
"Currency is required.");
}
}
For larger systems, use a schema definition such as JSON Schema or an event schema registry.
Schema validation can detect missing required fields, incorrect data types, and unsupported contract changes before messages reach consumers.
Validation should also run in automated tests and deployment pipelines.
Test Compatibility Before Deployment
A successful build does not prove that an event change is compatible with existing services.
Create contract tests that use representative messages from older versions.
For example:
[Fact]
public async Task Consumer_ShouldProcessV1Event()
{
var payload = """
{
"orderId": 4521,
"amount": 1500
}
""";
var exception = await Record.ExceptionAsync(
() => _consumer.HandleAsync(
payload,
schemaVersion: 1,
CancellationToken.None));
Assert.Null(exception);
}
Add equivalent tests for the new version.
Useful compatibility tests include:
New consumers can process old messages.
Old consumers can handle additional optional fields.
Missing optional fields receive expected defaults.
Invalid message versions are rejected.
Required fields are validated.
Historical messages can still be replayed.
Duplicate event delivery does not repeat business operations.
Run these tests against the actual serializer configuration used in production. Serializer settings can change how unknown properties, missing values, and naming conventions are handled.
Common Mistakes to Avoid
One common mistake is changing an event property without checking its consumers. A property that looks unused in the producer may be essential to another service.
Another mistake is renaming or removing fields in a shared event contract without introducing a migration period.
Using database entities as event contracts also creates unnecessary coupling between storage changes and service communication.
Developers sometimes rely only on unit tests for the producer. Consumer contract tests are equally important because the producer cannot know how every downstream application handles a message.
Finally, avoid deleting old event versions too early. Messages may remain in queues, dead-letter storage, or historical archives long after the producer has moved to a newer version.
Summary
Event schemas need to evolve as business requirements change, but producers and consumers rarely update at the same time.
Use explicit event contracts, add optional fields when possible, and introduce versioned contracts for breaking changes. Update consumers before removing older formats, and validate compatibility with automated contract tests.
A controlled expand-and-contract migration gives teams time to update services independently while keeping event processing reliable.
The objective is simple: change the information your services exchange without forcing every application to deploy at the same time.
Join the conversation! Your thoughts help the community grow.