Ship it; consumers must ignore unknown fields (System.Text.Json and Zod z.object do by default)
Add a value to an enum-like field
Yes, if documented as extensible
Consumers treat unknown values as "other"; never switch without a default
Make an optional field required
No
New version
Remove or rename a field
No
New version; keep the old name populated in .vN until retired
Change a type (string → number, date format)
No
New version
Change the meaning of a field (gross → net amount)
No, even if the type is unchanged
New version with a new field name
Change the partition key
No
New topic or new version; consumers assume ordering per key
Change the topic/queue name
No
New destination with dual-publish
Dual-publish recipe
Publish orders.order-placed.v2 next to .v1 from the same outbox transaction (two rows).
Migrate consumers one by one to .v2; each consumer's inbox key is (message_id, consumer), so both versions can coexist without double effects only if consumers subscribe to one version at a time.
Watch consumption of .v1 (consumer group lag, subscription metrics) until it is zero for a full retention period.
Stop producing .v1; delete the subscription after the retention window.
Header set
Header
Purpose
message-id
Dedupe key; equals the outbox row id
message-type
orders.order-placed.v1
occurred-at
RFC 3339 UTC, producer clock
aggregate-id / partition key
Ordering key
correlation-id
The business flow (order id, request id)
causation-id
The message or request that caused this one
traceparent
W3C trace context; propagated by OpenTelemetry instrumentation for most broker SDKs
tenant-id
Never inferred from the payload on the consumer side
content-type
application/json, application/avro, …
Schema publication options
Option
Fits
Cost
JSON Schema files in the owner's *.Contracts package (NuGet or npm)
Modular monolith or few services in one org
Consumers must update the package to see changes
Schema registry (Confluent, Apicurio, Azure Schema Registry) with Avro/Protobuf
Kafka or Event Hubs, many consumers, compatibility rules enforced at publish time
Operate the registry; producers fail fast on incompatible schemas
AsyncAPI document per service
Documentation and generated consumer stubs
Keep it in CI like OpenAPI (see api-design)
Consumer tolerance rules
Parse with a schema that ignores unknown fields and rejects wrong types (Zod: default z.object strips unknown keys; .NET: JsonSerializerOptions default ignores unmapped properties).
Treat a schema-invalid message as a permanent failure: dead-letter it with the validation error; do not retry.
Never depend on field order, on the absence of a field to mean "false", or on the producer's clock for business decisions.
# Event versioning and contracts
Companion to `messaging-patterns` §4.
## Compatibility matrix
| Change to the payload | Compatible within `.vN`? | What to do |
|---|---|---|
| Add an optional field | Yes | Ship it; consumers must ignore unknown fields (System.Text.Json and Zod `z.object` do by default) |
| Add a value to an enum-like field | Yes, if documented as extensible | Consumers treat unknown values as "other"; never `switch` without a default |
| Make an optional field required | No | New version |
| Remove or rename a field | No | New version; keep the old name populated in `.vN` until retired |
| Change a type (`string` → `number`, date format) | No | New version |
| Change the meaning of a field (gross → net amount) | No, even if the type is unchanged | New version with a new field name |
| Change the partition key | No | New topic or new version; consumers assume ordering per key |
| Change the topic/queue name | No | New destination with dual-publish |
## Dual-publish recipe
1. Publish `orders.order-placed.v2` next to `.v1` from the same outbox transaction (two rows).
2. Migrate consumers one by one to `.v2`; each consumer's inbox key is `(message_id, consumer)`, so both versions can coexist without double effects only if consumers subscribe to **one** version at a time.
3. Watch consumption of `.v1` (consumer group lag, subscription metrics) until it is zero for a full retention period.
4. Stop producing `.v1`; delete the subscription after the retention window.
## Header set
| Header | Purpose |
|---|---|
| `message-id` | Dedupe key; equals the outbox row id |
| `message-type` | `orders.order-placed.v1` |
| `occurred-at` | RFC 3339 UTC, producer clock |
| `aggregate-id` / partition key | Ordering key |
| `correlation-id` | The business flow (order id, request id) |
| `causation-id` | The message or request that caused this one |
| `traceparent` | W3C trace context; propagated by OpenTelemetry instrumentation for most broker SDKs |
| `tenant-id` | Never inferred from the payload on the consumer side |
| `content-type` | `application/json`, `application/avro`, … |
## Schema publication options
| Option | Fits | Cost |
|---|---|---|
| JSON Schema files in the owner's `*.Contracts` package (NuGet or npm) | Modular monolith or few services in one org | Consumers must update the package to see changes |
| Schema registry (Confluent, Apicurio, Azure Schema Registry) with Avro/Protobuf | Kafka or Event Hubs, many consumers, compatibility rules enforced at publish time | Operate the registry; producers fail fast on incompatible schemas |
| AsyncAPI document per service | Documentation and generated consumer stubs | Keep it in CI like OpenAPI (see `api-design`) |
## Consumer tolerance rules
- Parse with a schema that **ignores unknown fields** and **rejects wrong types** (Zod: default `z.object` strips unknown keys; .NET: `JsonSerializerOptions` default ignores unmapped properties).
- Treat a schema-invalid message as a permanent failure: dead-letter it with the validation error; do not retry.
- Never depend on field order, on the absence of a field to mean "false", or on the producer's clock for business decisions.