What event versioning is and why events are public API contracts

Event versioning is the practice of changing an event's payload while consumers keep working, whether they run old code or new. Event schema evolution is the broader process: the sequence of schema revisions an event type goes through and the rules that decide which revisions can coexist.

A published event is a public API contract, and usually a harder one to manage than an HTTP endpoint. The producer often does not know every consumer. New subscribers attach without asking. Consumers deploy on their own schedules, and messages wait in queues or retained logs after the code that wrote them has been replaced. You cannot coordinate a single cutover, so every change has to be safe while producers and consumers run mixed versions. This applies to backend and platform engineers running asynchronous, independently deployed services.

Compatibility is defined by which schema reads which data:

Compatibility Guarantee Required when
Backward Consumers on the new schema can read events written with the old schema Consumers deploy first, or any consumer replays historical events
Forward Consumers on the old schema can read events written with the new schema Producers deploy first while old consumers are still running
Full Both directions hold Deploy order is uncontrolled, or producer-first deploys combined with replay

Replay matters as much as deploy order. If events are retained in a log or event store, new consumer code will eventually read events written years ago. That makes backward compatibility a permanent requirement, not a property you only need during a rollout window.

Safe changes, breaking changes, and the ones schemas cannot see

Compatibility is judged from the reader's side. A change is safe only if every consumer, old or new, can still interpret every event it might receive, including events written long before the change.

Change Old consumer, new event New consumer, old event Verdict
Add optional field with default Ignores unknown field Applies default Safe
Remove a field Expected value is missing Unaffected Breaking
Rename a field Old name is missing New name is missing Breaking
Change type (e.g. string to integer) Deserialization fails Deserialization fails Breaking
Make optional field required Unaffected Rejects historical events Breaking
Add an enum value Exhaustive match may fail Unaffected Risky

Schema compatibility checkers catch most of these structural changes. They cannot catch semantic ones. Suppose amount changes from dollars to cents, or createdAt changes from local time to UTC. The type and name stay the same, so the schema still validates. Every consumer that was not redeployed will now misread the value. Usually nothing throws an error. You get wrong totals and wrong reports, and nobody notices for weeks.

Do not change what an existing field means in place. Introduce a new field such as amountCents and deprecate the old one. If the event's meaning itself has shifted, introduce a new event type. A new name forces each consumer to opt in explicitly.

Additive-only evolution has a cost. Over years, payloads accumulate deprecated and optional fields. Consumers must handle every combination of present and absent fields, and the schema stops describing what producers actually send. Plan periodic consolidation, which later sections cover, rather than letting the schema grow indefinitely.

Designing a stable envelope and a tolerant reader

Separate what every event shares from what each event says. The envelope carries metadata that routing, deduplication, and observability depend on, so it should change almost never. The payload carries business data and evolves on its own schedule.

A practical envelope holds six fields:

  • type: a stable name such as order.placed, used for routing and handler selection.
  • schemaVersion: the payload's version, read before the payload is parsed.
  • eventId: a unique identifier that lets consumers deduplicate redeliveries.
  • occurredAt: when the business fact happened, not when it was published.
  • producer: the emitting service, which tells you who to contact when a payload looks wrong.
  • payload: the versioned body.

Because infrastructure code only touches the envelope, a payload change never forces a change to dead-letter handlers, tracing, or replay tooling.

On the consumer side, apply the tolerant reader pattern. Configure deserializers to ignore unknown properties rather than fail on them, and map only the fields the handler actually uses. A billing consumer that needs orderId and total should not break when the producer adds giftMessage. The narrower the read, the fewer producer changes can affect it.

The cost is that tolerance hides real problems. If a producer renames total to amount, a lenient deserializer silently yields null or zero, and the consumer may bill nothing. Counter this by validating required fields explicitly after deserialization. Reject or dead-letter events missing what the handler depends on, and emit a metric when that happens. Ignore extra fields, but fail loudly on missing ones.

// Stable envelope: infrastructure reads only these fields; payload evolves separately.
interface Envelope {
  type: string; schemaVersion: number; eventId: string;
  occurredAt: string; producer: string; payload: unknown;
}

const raw = JSON.stringify({
  type: "order.placed", schemaVersion: 2,
  eventId: "7f3c9a2e-4b1d-4e8a-9c55-0d2f6b8e1a47",
  occurredAt: "2026-10-28T09:14:03Z", producer: "order-service",
  // giftMessage is new and unknown to billing; it must not break the reader.
  payload: { orderId: "ord-1042", total: 59.9, currency: "EUR", giftMessage: "Happy birthday" },
});

interface BillingInput { orderId: string; total: number; }

function readOrderPlaced(json: string): BillingInput {
  const env = JSON.parse(json) as Envelope;
  if (env.type !== "order.placed" || ![1, 2].includes(env.schemaVersion)) {
    throw new Error(`unsupported ${env.type} v${env.schemaVersion}`);
  }
  // Tolerant reader: look only at the fields billing uses; everything else is ignored.
  const p = (env.payload ?? {}) as Record<string, unknown>;
  // Explicit validation: a renamed field (total -> amount) fails here instead of billing 0.
  const missing: string[] = [];
  if (typeof p.orderId !== "string") missing.push("orderId");
  if (typeof p.total !== "number" || !Number.isFinite(p.total)) missing.push("total");
  if (missing.length > 0) {
    throw new Error(`event ${env.eventId} from ${env.producer} missing: ${missing.join(", ")}`);
  }
  return { orderId: p.orderId as string, total: p.total as number };
}

try {
  console.log(readOrderPlaced(raw)); // { orderId: 'ord-1042', total: 59.9 }
} catch (err) {
  // In a real consumer: route to the dead-letter queue and increment a metric.
  console.error("dead-lettering:", (err as Error).message);
}

A stable event envelope and a TypeScript billing consumer that ignores unknown payload fields but explicitly rejects events missing the fields it depends on.

Choosing a versioning strategy: version field, versioned type names, or separate topics

Most additive changes need no version marker once consumers tolerate unknown fields. A marker matters only when a change is breaking. There are three common places to put it, and they are alternatives: pick one per event family rather than combining them.

Strategy Fits when Avoid when Main cost
Version field in envelope or headers One handler can branch or upcast per version Routers or filters key only on type name Every consumer must read and branch on the field
Versioned type name (OrderPlaced vs OrderPlacedV2) The meaning changed, not just the shape Changes are frequent, causing type-name sprawl Subscriptions and handlers must list each name
Separate topic or stream per version Consumers migrate on very different timelines Per-entity ordering matters Duplicate infrastructure and dual publishing

A version field keeps one stream and one subscription, so ordering is untouched. Use a versioned type name when V2 is a semantically different fact. A new name forces each consumer to opt in explicitly instead of misreading the new payload.

Per-version topics look clean but are the most expensive option. Each topic needs its own retention, access control, monitoring and consumer groups. Events for the same order can now sit in two logs, so no consumer can rely on their relative order. During migration the producer must publish to both topics atomically, typically through an outbox. Consumers that switch topics need idempotency to absorb overlap.

Rewriting stored events into the new shape is the fourth alternative. It works only when you own every copy of the data. It fails once events have left your store, and it destroys the audit value of an immutable log.

How upcasting events works at the consumer or storage boundary

Upcasting avoids that loss. Stored events stay exactly as they were written. When a consumer reads an old event, it passes through a chain of small functions, each converting one version to the next, until it reaches the shape the handler expects:

event store or log → deserialize raw payload → upcast v1→v2 → upcast v2→v3 → handler (sees only v3)

Each function handles one step. The v1→v2 upcaster might split a name field into firstName and lastName. The v2→v3 upcaster might move a flat amount into a {value, currency} object and default the currency. A v1 event runs through both. A v2 event runs through only the second. Handlers contain no version checks. When v4 arrives, you add one v3→v4 function and leave the existing ones alone.

This matters most in event-sourced systems. There, the stored events are the source of truth, and aggregates are rebuilt by replaying them. You cannot change history, but your code must still be able to read all of it.

Test each step in isolation with a fixture payload captured from the real producer at that version. Also test the full chain end to end from the oldest version.

The trade-offs are real:

  • Read-time cost. Every replay or projection rebuild pays the transformation cost for every old event. Snapshots reduce the cost, but they do not remove it.
  • Permanent maintenance. An upcaster can only be deleted when no stored event of that version remains. For most event stores, that day never comes.
  • Rewriting instead is not a shortcut. Rewriting means mutating the audit trail. On a live system, it also means racing concurrent writers and running a migration whose failure leaves you with mixed shapes.
import { test } from "node:test";
import assert from "node:assert/strict";

type V1 = { version: 1; name: string; amount: number };
type V2 = { version: 2; firstName: string; lastName: string; amount: number };
type V3 = { version: 3; firstName: string; lastName: string; amount: { value: number; currency: string } };
type StoredEvent = V1 | V2 | V3;

// One small function per step. Adding v4 means adding one 3 -> 4 entry.
const upcasters: Record<number, (e: any) => StoredEvent> = {
  1: (e: V1): V2 => {
    const [firstName, ...rest] = e.name.split(" ");
    return { version: 2, firstName, lastName: rest.join(" "), amount: e.amount };
  },
  // Events before v3 had no currency. EUR stands in for the business-defined default.
  2: (e: V2): V3 => ({ ...e, version: 3, amount: { value: e.amount, currency: "EUR" } }),
};

// Runs at read time. The stored event is never modified.
export function upcast(raw: StoredEvent): V3 {
  let e = raw;
  while (e.version < 3) {
    const step = upcasters[e.version];
    if (!step) throw new Error(`no upcaster for version ${e.version}`);
    e = step(e);
  }
  return e as V3;
}

// Fixtures should be payloads captured from the producer at each version.
const v1: V1 = { version: 1, name: "Ada Lovelace", amount: 4200 };
const v2: V2 = { version: 2, firstName: "Ada", lastName: "Lovelace", amount: 4200 };
const v3: V3 = { version: 3, firstName: "Ada", lastName: "Lovelace", amount: { value: 4200, currency: "EUR" } };

test("v1 -> v2 step in isolation", () => assert.deepEqual(upcasters[1](v1), v2));
test("v2 -> v3 step in isolation", () => assert.deepEqual(upcasters[2](v2), v3));
test("full chain from oldest version", () => assert.deepEqual(upcast(v1), v3));
test("current version passes through unchanged", () => assert.deepEqual(upcast(v3), v3));

A TypeScript upcaster chain that converts stored v1 and v2 events into the v3 shape at read time, with tests for each step in isolation and for the full chain starting from the oldest version.

Implementing a breaking change: CI compatibility checks and a multi-step migration

The cheapest place to stop a breaking change is the pull request that introduces it. Keep event schemas in a schema registry or a shared contract repository, and have CI compare each proposed schema against the versions already published. The check should enforce the compatibility mode the topic promises. For example, under backward compatibility, a new required field without a default, or a field removal that readers still expect, fails the build. A reviewer never has to notice it by eye.

When a change genuinely must break, split it into steps that are each safe on their own:

  1. Expand. Add the new field alongside the old one, or dual-publish v1 and v2 events. Producers write both shapes from a single transactional source. Typically that is an outbox row committed with the business change, so that a crash cannot emit one version without the other.
  2. Migrate. Move consumers to the new field one at a time. Each deploy is independent and reversible.
  3. Verify. Confirm that nothing still reads the old field. Use consumer registrations in the contract repository, consumer-group lag on the v1 topic, or field-level access logging.
  4. Contract. Remove the old field or stop publishing v1. Only now does the registry need to accept the breaking schema.

Structural checks have a blind spot. A field that changes from cents to dollars, or a status value whose meaning shifts, passes every schema diff. Catch these with explicit contract review and consumer-driven contract tests. Those tests run the producer against the expectations each consumer has published.

import json
import sqlite3
import uuid

conn = sqlite3.connect("orders.db")
conn.executescript("""
CREATE TABLE IF NOT EXISTS orders (id TEXT PRIMARY KEY, total_cents INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS outbox (
  id TEXT PRIMARY KEY, topic TEXT NOT NULL, schema_version INTEGER NOT NULL,
  payload TEXT NOT NULL, published INTEGER NOT NULL DEFAULT 0
);
""")

def place_order(order_id: str, total_cents: int) -> None:
    # Expand phase: both event shapes come from ONE transaction.
    # The connection context manager commits on success and rolls back on error,
    # so the order row, v1 event and v2 event all persist or none do.
    with conn:
        conn.execute("INSERT INTO orders (id, total_cents) VALUES (?, ?)",
                     (order_id, total_cents))
        events = [
            # v1: legacy shape. Remove only after the Verify step shows no readers.
            ("orders.placed.v1", 1, {"order_id": order_id, "amount": total_cents}),
            # v2: new shape. The unit is explicit, so 'amount' cannot silently change meaning.
            ("orders.placed.v2", 2, {"order_id": order_id,
                                    "amount": {"value_minor": total_cents, "currency": "EUR"}}),
        ]
        for topic, version, payload in events:
            conn.execute(
                "INSERT INTO outbox (id, topic, schema_version, payload) VALUES (?, ?, ?, ?)",
                (str(uuid.uuid4()), topic, version, json.dumps(payload)))

# A separate relay process reads rows WHERE published = 0, sends each one to its topic,
# and then marks it published. Delivery is at-least-once, so consumers must dedupe on outbox id.
# Contract phase: delete the v1 entry above and retire the orders.placed.v1 topic.
place_order("ord-1042", 4999)

In the expand step, a transactional outbox writes the business row and both the v1 and v2 events in one database transaction, so a crash can never publish one version without the other.

Impact on development cost and engineering time

The upfront cost is mostly one-time: an envelope library, a compatibility check wired into CI, and contract tests. The recurring cost is discipline. Every breaking change becomes an expand, migrate, contract sequence spread over several releases, plus an upcaster that must be written, tested, and eventually deleted.

Illustrative estimate (assumed numbers, not a benchmark)

Net hours saved per year = (coordinated releases avoided × engineers per release × hours each) + (incidents avoided × hours per incident) − (setup hours + breaking changes × hours per migration)

Assume 12 releases avoided × 4 engineers × 3 hours = 144, and 3 incidents × 16 hours = 48. Then subtract 80 hours of setup and 6 breaking changes × 12 hours = 152. That nets 40 hours in year one and about 120 in later years, once the setup cost is gone. Plug in your own numbers. If you have few breaking changes or few consumers, the result can easily go negative.

The hidden cost is the stalled deprecation. Old fields and upcasters linger because nobody can prove they are unused. You can get that proof in three ways:

  • Require consumers to register which event types and versions they read.
  • Emit a metric per consumer for each version it processes.
  • Track the oldest stored version in each event store or retained topic.

You cannot delete an upcaster while replayable data still holds that version. Either rewrite or snapshot that data, or keep the upcaster.

Lighter practices are enough when one team owns both producer and consumer and nothing replays old events. In that case a tolerant reader, plus deploying the consumer before the producer, covers most changes.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to event versioning microservices, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.