Event-Driven Architecture Lesson 5 of 6 ⏱️ 4 min read

Event Schemas, Versioning & Evolution

Design event payloads that last: backward and forward compatibility, versioning strategies and handling old events safely.

Updated Oct 30, 2026

Events are contracts

Once an event is published, other teams' services depend on its shape. In event-sourced systems you must also read events written years ago. Changing an event carelessly breaks consumers silently — often in production, at night.

A well-designed event

JSON
{
  "event_id": "5f0c9a2e-3d61-4b8e-9c7a-0f4b2a1d9e13",
  "event_type": "EnrollmentCreated",
  "event_version": 2,
  "occurred_at": "2026-10-29T10:02:11Z",
  "producer": "enrollment-service",
  "correlation_id": "req-7f3a…",
  "data": {
    "enrollment_id": 1043,
    "student_id": 42,
    "course_id": 7,
    "fee": { "amount": 5000, "currency": "PKR" }
  }
}
  • Envelope metadata (ID, type, version, time, producer, correlation ID) is consistent across all events.
  • data holds the business payload.
  • Explicit event_version lets consumers handle each shape correctly.
  • Money as amount + currency, timestamps in UTC ISO-8601, IDs as stable identifiers.

Compatibility rules

Change Backward compatible? Notes
Add an optional field ✅ Yes Old consumers ignore it; new consumers use a default when missing
Add a required field ❌ No Old events don't have it
Remove a field ❌ Usually no Consumers may still read it — deprecate first
Rename a field ❌ No Equivalent to remove + add
Change a field's type/meaning ❌ No Create a new version or a new event

Golden rule: consumers should be tolerant readers — ignore unknown fields and supply defaults for missing optional ones. Producers should only make additive changes within a version.

Versioning strategies

  1. Additive changes only — the simplest; most changes can be expressed as new optional fields.
  2. Version number in the event (event_version: 2) — consumers branch on it or upcast.
  3. New event type — for a real change in meaning (EnrollmentCreatedV2, or better, a new name like EnrollmentStarted).
  4. Publish both versions for a migration period, then retire the old one once every consumer has moved.

Upcasting old events

An upcaster converts older event versions into the current shape when they're read, so business code only deals with the latest version.

Suppose version 1 stored the fee as a plain number in PKR, and version 2 introduced currencies:

Python
def upcast(event: dict) -> dict:
    """Convert any stored EnrollmentCreated event to the current (v2) shape."""
    version = event.get("event_version", 1)
    if event["event_type"] == "EnrollmentCreated" and version == 1:
        data = dict(event["data"])
        data["fee"] = {"amount": data.pop("fee_pkr"), "currency": "PKR"}
        return {**event, "event_version": 2, "data": data}
    return event

old = {"event_type": "EnrollmentCreated", "event_version": 1,
       "data": {"enrollment_id": 7, "fee_pkr": 5000}}
new = {"event_type": "EnrollmentCreated", "event_version": 2,
       "data": {"enrollment_id": 8, "fee": {"amount": 40, "currency": "USD"}}}

for e in (old, new):
    print(upcast(e)["data"])

Output:

Plaintext
{'enrollment_id': 7, 'fee': {'amount': 5000, 'currency': 'PKR'}}
{'enrollment_id': 8, 'fee': {'amount': 40, 'currency': 'USD'}}

Stored events are never rewritten; the conversion happens on read.

Schema registries

In larger systems, event schemas are stored in a schema registry (commonly with Avro, Protobuf or JSON Schema). Producers register schemas, and the registry rejects incompatible changes before they reach production. See the Kafka tutorial's Schema Registry & Avro lesson for a hands-on example.

Designing events that age well

  • Name events after business facts (CoursePublished), not database operations (CourseRowUpdated).
  • Include what consumers need, not the entire internal object — every field is a promise.
  • Avoid leaking internal details (database IDs from other systems, internal status codes).
  • Document events like an API: owner, meaning, fields, examples, version history.
  • Test compatibility in CI with contract tests or registry checks.

Common mistakes

  • Renaming a field "just to tidy up" and breaking downstream consumers.
  • Strict consumers that fail on unknown fields.
  • No version information, making it impossible to tell old shapes from new.
  • Changing the meaning of a field while keeping its name (e.g. amount switching from rupees to paisa).

Key takeaways

  • Treat events as public contracts with a consistent envelope and explicit versions.
  • Prefer additive, optional changes; consumers should be tolerant readers.
  • Upcast old events on read instead of rewriting history.
  • Use schema registries and contract tests to catch breaking changes early.
Finished this lesson?
Tick it off to track your Event-Driven Architecture progress.
Next lesson