Loading learning content...
Software systems evolve continuously. Requirements change, domains are better understood, and new features demand new data. In event-driven systems, this evolution manifests as schema changes to the events that flow between services.
Without a disciplined versioning strategy, schema changes become a source of fear:
Event versioning is the discipline of evolving event schemas systematically, ensuring that producers and consumers can continue to communicate as schemas change over time. It's the difference between confident evolution and deployment terror.
By the end of this page, you will understand: explicit versioning strategies (version numbers, event type naming), versioning patterns (single writer principle, upcasting, schema-on-read), handling breaking changes through event type evolution, and practical approaches to version negotiation and migration.
Unlike synchronous APIs where both parties are online simultaneously, events introduce temporal decoupling. A producer might emit an event today that a consumer processes tomorrow, next week, or even months later (in event-sourced systems that replay historical events).
This temporal dimension creates unique versioning challenges:
Every event you publish is an API contract with every consumer. Just as you wouldn't change a REST API path without versioning, you shouldn't change an event schema without versioning. The difference: APIs are synchronous and can be upgraded together; events are asynchronous and must handle multiple versions simultaneously.
There are several approaches to making event versions explicit. Each has trade-offs, and the best choice depends on your system's constraints and evolution patterns.
Strategy 1: Version Number in Event Schema
The most straightforward approach: include a version number field in every event.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
// Version 1 of the eventinterface OrderPlacedEventV1 { eventType: "order.placed"; version: 1; // Explicit version number eventId: string; occurredAt: string; payload: { orderId: string; customerId: string; totalCents: number; };} // Version 2: Added shipping informationinterface OrderPlacedEventV2 { eventType: "order.placed"; version: 2; // Bumped version eventId: string; occurredAt: string; payload: { orderId: string; customerId: string; totalCents: number; shippingMethod: string; // New field shippingCents: number; // New field };} // Consumer handles multiple versionsclass OrderPlacedHandler { handle(event: OrderPlacedEventV1 | OrderPlacedEventV2): void { switch (event.version) { case 1: this.handleV1(event); break; case 2: this.handleV2(event); break; default: throw new UnknownVersionError( `Unknown version: ${(event as any).version}` ); } } private handleV1(event: OrderPlacedEventV1): void { // V1 logic - default shipping values this.processOrder({ orderId: event.payload.orderId, shippingMethod: "STANDARD", // Default for V1 shippingCents: 0, }); } private handleV2(event: OrderPlacedEventV2): void { // V2 logic - use provided values this.processOrder({ orderId: event.payload.orderId, shippingMethod: event.payload.shippingMethod, shippingCents: event.payload.shippingCents, }); }}Strategy 2: Version in Event Type Name
Include the version directly in the event type identifier.
123456789101112131415161718192021222324252627282930313233343536373839404142434445
// Version in the type name itselfinterface OrderPlacedV1 { eventType: "order.placed.v1"; // Version in type eventId: string; occurredAt: string; payload: { orderId: string; customerId: string; totalCents: number; };} interface OrderPlacedV2 { eventType: "order.placed.v2"; // New type name eventId: string; occurredAt: string; payload: { orderId: string; customerId: string; totalCents: number; shippingMethod: string; shippingCents: number; };} // Router dispatches based on type nameclass EventRouter { private readonly handlers = new Map<string, EventHandler>(); constructor() { // Register handlers by type this.handlers.set("order.placed.v1", new OrderPlacedV1Handler()); this.handlers.set("order.placed.v2", new OrderPlacedV2Handler()); } route(event: { eventType: string }): void { const handler = this.handlers.get(event.eventType); if (!handler) { // Unknown type - could be newer version we don't handle this.logUnknownEvent(event); return; } handler.handle(event); }}Strategy 3: Semantic Versioning for Events
Apply semantic versioning (major.minor.patch) to event schemas.
1234567891011121314151617181920212223242526272829303132333435363738
// Semantic versioning: MAJOR.MINOR.PATCHinterface VersionedEvent { eventType: string; schemaVersion: { major: number; // Breaking changes minor: number; // Backward-compatible additions patch: number; // Backward-compatible fixes }; // ... rest of event} // Examples:// 1.0.0 → 1.1.0: Added optional field (minor)// 1.1.0 → 1.1.1: Fixed typo in documentation (patch) // 1.1.1 → 2.0.0: Removed required field (major/breaking) // Consumer version compatibility checkclass VersionedEventHandler { // Handler supports version 1.x.x private readonly supportedMajor = 1; canHandle(event: VersionedEvent): boolean { // Must match major version; any minor/patch OK return event.schemaVersion.major === this.supportedMajor; } handle(event: VersionedEvent): void { if (!this.canHandle(event)) { throw new UnsupportedVersionError( `This handler supports version 1.x.x, got ${ event.schemaVersion.major }.${event.schemaVersion.minor}.${event.schemaVersion.patch}` ); } // Process event - minor/patch differences handled gracefully this.processEvent(event); }}| Strategy | Pros | Cons | Best For |
|---|---|---|---|
| Version field | Simple, explicit, easy to switch on | Must handle multiple versions in one handler | Most use cases |
| Version in type name | Clear separation, easier routing | Topic/queue proliferation for each version | Event sourcing |
| Semantic versioning | Precise compatibility signaling | More complex, may be overkill | Public APIs, external consumers |
Upcasting is a powerful pattern for handling multiple event versions without polluting your business logic with version-specific code. The idea: transform old event versions into the latest version before processing.
The Benefit:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
// Upcaster: transforms one version to the nextinterface Upcaster<TFrom, TTo> { fromVersion: number; toVersion: number; upcast(event: TFrom): TTo;} // V1 → V2: Add default shippingclass OrderPlacedV1ToV2Upcaster implements Upcaster<OrderPlacedV1, OrderPlacedV2> { readonly fromVersion = 1; readonly toVersion = 2; upcast(event: OrderPlacedV1): OrderPlacedV2 { return { eventType: "order.placed", version: 2, eventId: event.eventId, occurredAt: event.occurredAt, payload: { ...event.payload, // Add default values for new fields shippingMethod: "STANDARD", shippingCents: 0, }, }; }} // V2 → V3: Refactor customer to separate objectclass OrderPlacedV2ToV3Upcaster implements Upcaster<OrderPlacedV2, OrderPlacedV3> { readonly fromVersion = 2; readonly toVersion = 3; upcast(event: OrderPlacedV2): OrderPlacedV3 { return { eventType: "order.placed", version: 3, eventId: event.eventId, occurredAt: event.occurredAt, payload: { orderId: event.payload.orderId, // Transform flat customerId to customer object customer: { id: event.payload.customerId, tier: "UNKNOWN", // Can't derive from old data }, totals: { subtotalCents: event.payload.totalCents - event.payload.shippingCents, shippingCents: event.payload.shippingCents, totalCents: event.payload.totalCents, }, shippingMethod: event.payload.shippingMethod, }, }; }} // Upcasting chain: transforms any version to latestclass UpcastingChain { private readonly upcasters: Map<number, Upcaster<any, any>> = new Map(); constructor(upcasters: Upcaster<any, any>[]) { for (const upcaster of upcasters) { this.upcasters.set(upcaster.fromVersion, upcaster); } } upcastToLatest<T>(event: { version: number }): T { let current = event; let version = event.version; // Chain upcasters until we reach latest version while (this.upcasters.has(version)) { const upcaster = this.upcasters.get(version)!; current = upcaster.upcast(current); version = upcaster.toVersion; } return current as T; }} // Usage in event handlerclass OrderEventProcessor { private readonly upcastingChain = new UpcastingChain([ new OrderPlacedV1ToV2Upcaster(), new OrderPlacedV2ToV3Upcaster(), ]); handle(event: OrderPlacedV1 | OrderPlacedV2 | OrderPlacedV3): void { // Always upcast to V3 (latest) const latestEvent = this.upcastingChain.upcastToLatest<OrderPlacedV3>(event); // Business logic only handles V3 this.processOrderV3(latestEvent); } private processOrderV3(event: OrderPlacedV3): void { // Clean business logic with no version switches const order = this.createOrder(event.payload); this.orderRepository.save(order); }}Upcasting transforms old → new; downcasting transforms new → old. Upcasting is generally preferred because business logic can assume the latest schema. Downcasting is sometimes needed when old consumers can't be updated and must receive events in old formats.
A fundamental principle for manageable event versioning is the Single Writer Principle: each event type should have exactly one service that produces it.
Why Single Writer Matters:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
// CORRECT: Single writer for each event type// Order Service owns all order eventsclass OrderService { // ONLY the Order Service publishes OrderPlaced async placeOrder(command: PlaceOrderCommand): Promise<Order> { const order = Order.create(command); await this.orderRepository.save(order); // Single point of event production await this.eventBus.publish( this.createOrderPlacedEvent(order) ); return order; } // ONLY the Order Service publishes OrderCancelled async cancelOrder(orderId: string): Promise<void> { const order = await this.orderRepository.get(orderId); order.cancel(); await this.orderRepository.save(order); await this.eventBus.publish( this.createOrderCancelledEvent(order) ); }} // INCORRECT: Multi-writer anti-pattern// Multiple services publish the same event typeclass OrderService { async placeOrder(command: PlaceOrderCommand): Promise<void> { // Order Service publishes OrderPlaced await this.eventBus.publish({ eventType: "order.placed", version: 1, // ... }); }} class LegacyOrderImporter { async importOrder(legacyOrder: LegacyOrder): Promise<void> { // Importer ALSO publishes OrderPlaced - BAD! // Might use different version, different fields await this.eventBus.publish({ eventType: "order.placed", version: 1, // Or is it version 2? // Different field structure? }); }} // CORRECT: Funnel through single writerclass LegacyOrderImporter { constructor(private orderService: OrderService) {} async importOrder(legacyOrder: LegacyOrder): Promise<void> { // Convert to command, let OrderService handle event const command = this.convertToCommand(legacyOrder); await this.orderService.placeOrder(command); }}Sometimes multi-writer is unavoidable (e.g., during migrations or with legacy systems). In these cases, establish strict schema governance: shared schema repository, CI compatibility checks, and coordinated deployment processes. But always prefer single writer when possible.
Despite best efforts at backward compatibility, sometimes breaking changes are unavoidable:
The Golden Rule: Never modify an existing event type incompatibly. Instead, introduce a new event type.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
// ORIGINAL: Flawed design we need to changeinterface OrderPlacedV1 { eventType: "order.placed"; version: 1; payload: { orderId: string; // Problem: customer data is denormalized customerName: string; customerEmail: string; customerPhone: string; customerAddress: string; // Monolithic totals field total: string; // "$100.00" - should be cents! };} // WRONG: Incompatible modification to same eventinterface OrderPlacedV2Wrong { eventType: "order.placed"; version: 2; payload: { orderId: string; // Breaking: customer fields removed/moved customerId: string; // Different! // Breaking: total is now structured totals: { subtotalCents: number; taxCents: number; shippingCents: number; totalCents: number; }; };}// This breaks existing consumers expecting V1 structure! // CORRECT: Introduce new event typeinterface OrderPlacedV2Correct { eventType: "order.placed.v2"; // New type! eventId: string; occurredAt: string; payload: { orderId: string; customerId: string; totals: { subtotalCents: number; taxCents: number; shippingCents: number; totalCents: number; }; };} // Producer publishes BOTH during migrationclass OrderService { async placeOrder(command: PlaceOrderCommand): Promise<Order> { const order = Order.create(command); await this.orderRepository.save(order); // Publish both versions during migration await this.eventBus.publish([ this.createOrderPlacedV1(order), // For old consumers this.createOrderPlacedV2(order), // For new consumers ]); return order; }} // Migration timeline:// Phase 1: Producer emits both V1 and V2// Phase 2: Consumers migrate to V2 (their pace)// Phase 3: After all consumers migrated, stop V1 emission// Phase 4: Remove V1 code| Phase | Producer State | Consumer State | Duration |
|---|---|---|---|
| Emits V1 + V2 | Consuming V1 | Start immediately |
| Emits V1 + V2 | Migrating to V2 | Weeks to months |
| Emits V1 + V2, warns on V1 | Mostly V2 | 2-4 weeks |
| Emits V2 only | All on V2 | Complete |
Don't underestimate migration timelines. Other teams have priorities; they can't drop everything to migrate. Plan for 3-6 months of dual-version operation. Build monitoring to track V1 consumption, and don't remove V1 until consumption reaches zero.
Two fundamental approaches exist for event schema handling:
Schema-on-Write: Events are validated against a schema when produced. Invalid events are rejected. Consumers trust the schema is enforced.
Schema-on-Read: Events are stored as-is without strict validation. Consumers interpret/validate events when reading. More flexible but requires defensive coding.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
// Schema-on-Read: Consumer must handle unknown/partial dataclass DefensiveEventHandler { handle(rawEvent: unknown): void { // Parse and validate defensively const event = this.parseEvent(rawEvent); if (!event) { this.logUnparsableEvent(rawEvent); return; // Skip unparsable events } // Route based on what we understand if (this.isOrderPlaced(event)) { this.handleOrderPlaced(event); } else if (this.isOrderCancelled(event)) { this.handleOrderCancelled(event); } else { this.logUnknownEventType(event); // Don't crash! Just skip unknown types } } private parseEvent(raw: unknown): Record<string, unknown> | null { try { if (typeof raw === "string") { return JSON.parse(raw); } if (typeof raw === "object" && raw !== null) { return raw as Record<string, unknown>; } return null; } catch { return null; } } private isOrderPlaced(event: Record<string, unknown>): boolean { return ( event.eventType === "order.placed" && typeof event.payload === "object" && event.payload !== null && typeof (event.payload as any).orderId === "string" ); } private handleOrderPlaced(event: Record<string, unknown>): void { const payload = event.payload as Record<string, unknown>; // Extract required fields with defaults for optional/missing const orderId = payload.orderId as string; const customerId = (payload.customerId as string) ?? "UNKNOWN"; const totalCents = (payload.totalCents as number) ?? 0; // Handle fields that might exist in some versions const shippingMethod = (payload.shippingMethod as string) ?? "STANDARD"; // Handle unknown fields gracefully // (They might be from a newer version) const extraFields = Object.keys(payload).filter( k => !["orderId", "customerId", "totalCents", "shippingMethod"].includes(k) ); if (extraFields.length > 0) { console.log(`Ignoring unknown fields: ${extraFields.join(", ")}`); } this.processOrder({ orderId, customerId, totalCents, shippingMethod }); }}The Tolerant Reader pattern (from Martin Fowler) is schema-on-read in practice: 'Be conservative in what you send, be liberal in what you accept.' Extract only the fields you need, ignore unknown fields, and provide sensible defaults for missing optional fields.
In sophisticated event-driven systems, consumers may need to negotiate which event versions they can handle, or discover what versions are available.
Approaches to Version Discovery:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// Schema Registry stores all event schema versionsinterface SchemaRegistry { // Register new schema version register(eventType: string, schema: EventSchema): Promise<SchemaId>; // Get schema by ID getById(id: SchemaId): Promise<EventSchema | null>; // Get all versions for an event type getVersions(eventType: string): Promise<EventSchema[]>; // Check compatibility between versions checkCompatibility( eventType: string, newSchema: EventSchema, mode: CompatibilityMode ): Promise<CompatibilityResult>;} // Consumer queries registry to understand available versionsclass SchemaAwareConsumer { constructor(private registry: SchemaRegistry) {} async start(): Promise<void> { // Discover available event types and versions const orderEventVersions = await this.registry.getVersions("order.placed"); console.log("Available order.placed versions:"); for (const schema of orderEventVersions) { console.log(` - v${schema.version}: ${schema.fields.length} fields`); } // Determine which versions we can handle const supportedVersions = this.getSupportedVersions(); const compatible = orderEventVersions.filter( s => supportedVersions.includes(s.version) ); if (compatible.length === 0) { throw new Error( "No compatible event versions! Update this consumer." ); } console.log(`Will process versions: ${compatible.map(s => s.version).join(", ")}`); } private getSupportedVersions(): number[] { // Hardcoded or configurable list of supported versions return [1, 2, 3]; }}12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152
// Consumers advertise what versions they supportinterface ConsumerCapabilities { consumerId: string; eventSubscriptions: Array<{ eventType: string; supportedVersions: number[]; preferredVersion: number; }>;} // Producer can query consumer capabilitiesclass SmartEventPublisher { constructor( private capabilityRegistry: ConsumerCapabilityRegistry, private eventBus: EventBus, ) {} async publish(event: OrderPlacedEvent): Promise<void> { // Find consumers subscribed to this event const consumers = await this.capabilityRegistry.getSubscribers( event.eventType ); // Group by preferred version const versionGroups = this.groupByVersion(consumers); // Publish appropriate version to each group for (const [version, consumerGroup] of versionGroups) { const versionedEvent = this.convertToVersion(event, version); await this.eventBus.publishToConsumers( versionedEvent, consumerGroup.map(c => c.consumerId) ); } } private convertToVersion(event: OrderPlacedEvent, version: number): unknown { // Use downcasters for older versions switch (version) { case 3: return event; case 2: return this.downcastToV2(event); case 1: return this.downcastToV1(event); default: throw new Error(`Unknown version: ${version}`); } }} // Reality check: This level of smart routing is complex.// Most systems use simpler approaches:// - Publish all supported versions// - Consumers filter on their side// - Or: single version with upcastingVersion negotiation is powerful but complex. For most systems, simpler approaches work: 1) Publish latest version only, consumers upcast old events, or 2) Publish to versioned topics (e.g., 'orders.v1', 'orders.v2'), consumers subscribe to appropriate topics. Reserve complex negotiation for special cases.
Event versioning is essential for any long-lived event-driven system. Without it, evolution stalls and deployments become coordination nightmares.
What's Next:
With event serialization and versioning covered, we've addressed the core technical challenges of cross-boundary events. The final page of this module explores integration patterns—proven architectural patterns for integrating systems through events, including message channels, event brokers, outbox pattern, and saga orchestration.
You now understand the fundamentals of event versioning: explicit strategies, upcasting, single writer principle, breaking change management, and tolerant reading. You can design event schemas that evolve safely over years of production operation. Next, we'll explore higher-level integration patterns.