event-contracts-versioning
Event Contracts & Versioning
The problem: events are a public API
A REST API breaks loudly when you change it — clients get 400s. An event contract breaks silently: the publisher changes a field, consumers keep reading undefined, and you find out from corrupted data weeks later. Events outlive code: messages sit in queues (and outboxes, and DLQs) across deploys, so the version you published yesterday must still be parseable by the consumer you deploy tomorrow.
Our contract
Source of truth: contracts/events/ — JSON Schemas, language-neutral, owned by neither service.
Every message on banking.events is one envelope:
{
"messageId": "uuid — unique per message; dedupe key; STABLE across republications",
"correlationId": "uuid — one per saga; never changes as it flows",
"type": "Transfers.Transfer.Requested ({Domain}.{Entity}.{Action}, past tense for facts)",
"version": "1.0 — schema version of data",
"source": "transfer-service — who published it",
"timestamp": "ISO 8601",
"data": { "the actual payload" },
"metadata": { "traceId": "...", "spanId": "...", "userId": "..." }
}
metadata is now fully populated, not aspirational: transfer-service stamps the authenticated caller's Keycloak sub into userId (see oidc-keycloak-zero-trust.md), and OutboxService.enqueue captures the originating span's traceId/spanId (see distributed-tracing-otel.md). account-service consumers copy incoming metadata onto the events they publish, so the initiating user and trace context ride the entire saga — every message is attributable, even though the consumers run with no HTTP request in sight.
Routing key = type lowercased (transfers.transfer.requested), so consumers can bind with wildcards (accounts.account.*).
Conventions worth defending in review:
- Events are facts, named in past tense —
Debited, notDebitAccount. (OurCreditRequestedis honestly a command in event's clothing — a known choreography smell, worth mentioning in interviews.) - Money is integer minor units (
amountMinor), never floats —0.1 + 0.2 !== 0.3is not a rounding error you want in a ledger. - IDs are UUIDs generated by the owner of the aggregate.
- Envelope and payload are camelCase JSON — enforced on the .NET side by MassTransit's raw-JSON serializer (
UseRawJsonSerializer()inProgram.cs), so a C#recordand a TypeScript interface read the same bytes.
Evolution rules (the part interviewers probe)
Consumers must be tolerant readers: ignore unknown fields, never assume field order.
| Change | Compatible? | How |
|---|---|---|
| Add optional field | ✅ | just add; bump minor version (1.0 → 1.1) |
| Add required field | ⚠️ | only with a default the consumer can infer; otherwise it's breaking |
| Rename/remove/retype a field | ❌ breaking | new major version |
| New event type | ✅ | consumers bound by wildcard must park, not crash, on unknown types (see account-events.handler.ts default case) |
Breaking changes get a new version published alongside the old (type + version: 2.0, or a v2 routing key). Producers double-publish during migration; consumers upgrade at their own pace; the old version is retired when nobody consumes it. That's the expand–migrate–contract dance, same as DB migrations.
Where the contract is enforced
account-service/tests/.../SagaConsumerTests.csasserts the exact wire fields the Node side reads (camelCase envelope, routing keys).transfer-service/test/integration/saga.integration.spec.tsasserts the same in reverse, includingproperties.messageId === envelope.messageId.
These are hand-rolled contract tests — each side pins the other's expectations. Phase 6 graduates this to consumer-driven contracts (Pact) so the consumer's expectations generate the producer's test suite.
Interview Q&A
Q: Why an envelope at all? Why not just publish the payload? The envelope carries the cross-cutting concerns every event needs — identity (dedupe), causality (correlation), routing (type), evolution (version) — without polluting domain payloads. It's the messaging equivalent of HTTP headers.
Q: How do you change an event that 5 teams consume?
Additive changes: just ship, tolerant readers don't care. Breaking changes: publish v1 and v2 side by side, track consumer versions (the source field + broker metrics tell you who still reads v1), contract, then delete. You never coordinate a simultaneous deploy of 6 services — that's the distributed monolith failure mode.
Q: Schema registry — when do you need one?
When schemas-in-a-repo stops scaling: many teams, many languages, need for compile-time codegen and broker-side validation. AsyncAPI + a registry (or Confluent SR for Kafka) is the industrial version of our contracts/ directory. Same idea, more tooling.
Q: Why JSON and not Protobuf/Avro? Debuggability and zero toolchain friction beat compactness at this scale — you can read every message in the management UI. At serious throughput, binary formats with schema evolution support (Avro especially) win. Know the trade-off, don't cargo-cult either.
Try to break it
- Add a bogus field to a hand-published event — both consumers ignore it (tolerant reader, for free).
- Publish an event with
type: "Accounts.Account.Frozen"(routing keyaccounts.account.frozen). transfer-service retries then parks it — an unknown contract is quarantined, never guessed at. - Remove
currencyfrom a transfer event replay and watch the .NET consumer reject the currency mismatch path — required-field discipline in action.