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
- DynamoDB Streams Outbox: Domain Events Without Dual-Write Failures
- CQRS Boundary Criteria: When Separate Models Finally Earn Complexity Tax
- Idempotency Keys End-to-End
- Lambda Test Events as Contracts
Last updated on September 11, 2026
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.
