EventBridge Schema Evolution: Additive Changes Without Breaking Consumers

EventBridge Schema Evolution: Additive Changes Without Breaking Consumers

EventBridge makes publishing easy and breaking consumers even easier. A renamed field or tightened enum ships in minutes; twenty Lambdas fail overnight. Senior teams treat event schemas like public APIs: additive evolution, defaults on read, and contract tests that fail the producer’s PR before the bus sees the payload.

⚡ TL;DR: Register schemas; allow add-optional-field only by default. Consumers must ignore unknown fields and default missing optionals. Version major only on break; dual-publish during migration. Pair with DynamoDB Streams Outbox and CQRS Boundary Criteria.

Additive rules that CI can enforce

{
  "$id": "order.placed.v1",
  "type": "object",
  "additionalProperties": true,
  "required": ["orderId", "customerId", "total", "currency"],
  "properties": {
    "orderId": { "type": "string", "format": "uuid" },
    "customerId": { "type": "string" },
    "total": { "type": "integer", "minimum": 0 },
    "currency": { "type": "string", "enum": ["USD", "EUR", "INR"] },
    "promoCode": { "type": "string" }
  }
}
# ci/schema_diff.py
BREAKING = {"remove_required", "rename", "narrow_type", "remove_enum_value"}

def classify(old: dict, new: dict) -> str:
    # illustrative — use json-schema-diff / buf breaking in real CI
    removed_req = set(old.get("required", [])) - set(new.get("required", []))
    if removed_req:
        return "breaking"
    # ✅ Adding optional properties is additive
    return "additive"

❌ Shipping currency from string to {code, precision} in place — that is a major version, not a “cleanup.”

Consumer defaults and unknown fields

// consumers/orderPlaced.ts
type OrderPlacedV1 = {
  orderId: string;
  customerId: string;
  total: number;
  currency: "USD" | "EUR" | "INR";
  promoCode?: string;
};

export function parseOrderPlaced(raw: unknown): OrderPlacedV1 {
  const e = raw as Record<string, unknown>;
  // ✅ Ignore unknown keys; default optionals
  return {
    orderId: String(e.orderId),
    customerId: String(e.customerId),
    total: Number(e.total),
    currency: e.currency as OrderPlacedV1["currency"],
    promoCode: e.promoCode ? String(e.promoCode) : undefined,
  };
}

Dual-publish for major migrations

Phase Producer Consumers
0 v1 only all on v1
1 v1 + v2 (same transaction / outbox) migrate one-by-one
2 v2 only when %v1 consumers = 0 v2
3 remove v1 schema from registry

Wire dual-publish through the same outbox row with two detail-types (order.placed and order.placed.v2) so you never fork business transactions.

Contract tests on the producer PR

// __tests__/events.contract.test.ts
import { schemas } from "../schemas";
import { buildOrderPlaced } from "../producers/orders";

test("order.placed matches registry schema", () => {
  const evt = buildOrderPlaced(sampleOrder);
  expect(() => schemas["order.placed.v1"].validate(evt)).not.toThrow();
});

test("consumer fixture corpus still parses", () => {
  for (const fix of loadCorpus("order.placed")) {
    expect(() => parseOrderPlaced(fix)).not.toThrow();
  }
});

Closing checklist

✅ Dos
– ✅ Put schemas in registry / repo with review
– ✅ Allow only additive changes on minor versions
– ✅ Consumers ignore unknowns and default optionals
– ✅ Dual-publish across major migrations
– ✅ Fail producer CI on breaking schema diffs

❌ Don’ts
– ❌ Don’t rename fields in place
– ❌ Don’t require new fields without a defaulting window
– ❌ Don’t parse with as any and hope
– ❌ Don’t delete detail-types while consumers remain
– ❌ Don’t evolve enums by silently removing values

Related reading

Last updated on September 11, 2026


Discover more from CheatCoders

Subscribe to get the latest posts sent to your email.

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply