17

Module 17: Communication Patterns That Scale (Production Architecture)

Chapter 17 • Advanced

45 min

Communication Patterns That Scale (Production Architecture)

The Real Problem

Scenario: Products team adds a new event "cart:item-added-v2" with new structure. Checkout team hasn't updated yet. Checkout breaks.

The Question: How do I notify Checkout when Products adds an item, without breaking when Products team deploys a new version?

This is not about events. This is about contracts.


Communication as Contracts, Not Events

The Wrong Way (That Breaks)

typescript.js
// Products App
eventBus.emit('cart:item-added', {
  productId: '123',
  quantity: 2,
  price: 29.99
})

// Checkout App
eventBus.on('cart:item-added').subscribe(data => {
  // What if Products changes structure?
  // What if Products adds new fields?
  // What if Products removes fields?
  console.log(data.productId)  // Breaks if Products changes!
})

Why This Breaks:

  • ❌ No versioning
  • ❌ No backward compatibility
  • ❌ Tight coupling
  • ❌ Breaks on deployment

The Production Reality

Our E-Commerce System

  • Products App (Team A) - Emits events
  • Checkout App (Team B) - Listens to events
  • Host App (Platform Team) - Provides event bus

Constraints:

  • Teams deploy independently
  • Products can deploy before Checkout updates
  • Old Checkout must work with new Products
  • Network can fail
  • Events can be lost

The Solution: Typed, versioned event contracts.


Pattern 1: Typed Event Contracts (Recommended)

Define Event Schema

typescript.js
// Shared types (in Host or separate package)
export interface EventContract {
  event: string
  version: string
  data: any
  timestamp: number
}

export interface CartItemAddedEvent extends EventContract {
  event: 'cart:item-added'
  version: '1.0.0'
  data: {
    itemId: string
    productId: string
    quantity: number
    price: number
  }
}

Products App: Emit Typed Events

typescript.js
// products-app/src/app/events/cart.events.ts
export class CartEvents {
  static itemAdded(item: CartItem): CartItemAddedEvent {
    return {
      event: 'cart:item-added',
      version: '1.0.0',
      data: {
        itemId: item.id,
        productId: item.productId,
        quantity: item.quantity,
        price: item.price
      },
      timestamp: Date.now()
    }
  }
}
typescript.js
// products-app/src/app/cart/cart.service.ts
addItem(item: CartItem) {
  // Emit typed event
  const event = CartEvents.itemAdded(item)
  this.eventBus.emit(event)
}

Checkout App: Handle Events Safely

typescript.js
// checkout-app/src/app/events/event-handler.ts
export class EventHandler {
  constructor(private eventBus: EventBus) {
    this.eventBus.on('cart:item-added').subscribe(event => {
      this.handleCartItemAdded(event)
    })
  }
  
  private handleCartItemAdded(event: EventContract) {
    // Version check
    if (event.version === '1.0.0') {
      this.handleV1(event)
    } else if (event.version === '2.0.0') {
      this.handleV2(event)
    } else {
      console.warn('Unknown event version:', event.version)
      // Fallback to latest known version
      this.handleV1(event)
    }
  }
  
  private handleV1(event: CartItemAddedEvent) {
    const { itemId, productId, quantity, price } = event.data
    // Handle v1 structure
  }
  
  private handleV2(event: any) {
    // Handle v2 structure (when implemented)
  }
}

Why This Works

Version Safety - Old Checkout handles new Products events

Type Safety - TypeScript catches mismatches

Backward Compatible - New events don't break old listeners

Explicit Contracts - Event structure is documented


Pattern 2: Event Evolution Strategy

The Problem

Products wants to add "discount" field to cart:item-added event. Checkout hasn't updated yet.

The Solution: Additive Changes Only

typescript.js
// Version 1.0.0
interface CartItemAddedData {
  itemId: string
  productId: string
  quantity: number
  price: number
}

// Version 2.0.0 (ADDITIVE - doesn't break v1)
interface CartItemAddedData {
  itemId: string
  productId: string
  quantity: number
  price: number
  discount?: number  // Optional - v1 can ignore
  appliedPromo?: string  // Optional - v1 can ignore
}
typescript.js
// Checkout handles both
private handleCartItemAdded(event: EventContract) {
  if (event.version === '1.0.0') {
    // Old format - works fine
    const { itemId, productId, quantity, price } = event.data
  } else if (event.version === '2.0.0') {
    // New format - can use new fields
    const { itemId, productId, quantity, price, discount } = event.data
  }
}

Key Principle: New versions must be additive. Never remove fields.


Pattern 3: PostMessage for True Isolation

When to Use

  • Apps on different origins
  • Need true isolation
  • Security is critical

Implementation

typescript.js
// Products App (origin: products.example.com)
export class PostMessageService {
  send(event: EventContract) {
    window.parent.postMessage(event, 'https://app.example.com')
  }
}
typescript.js
// Host App (origin: app.example.com)
export class PostMessageListener {
  constructor() {
    window.addEventListener('message', (event) => {
      // Verify origin
      if (event.origin === 'https://products.example.com') {
        this.eventBus.emit(event.data)
      }
    })
  }
}

Security: Always verify origin. Never trust messages from unknown origins.


Pattern 4: Shared Services (Use Carefully)

When to Use

  • Simple, stable APIs
  • Synchronous communication needed
  • Same origin only

Implementation

typescript.js
// Host App - Provides service
@Injectable({ providedIn: 'root' })
export class NotificationService {
  private notifications$ = new Subject<Notification>()
  
  show(notification: Notification) {
    this.notifications$.next(notification)
  }
  
  getNotifications(): Observable<Notification> {
    return this.notifications$.asObservable()
  }
}
typescript.js
// Products App - Uses service
export class ProductsComponent {
  constructor(private notifications: NotificationService) {}
  
  addToCart() {
    this.notifications.show({
      message: 'Item added to cart',
      type: 'success'
    })
  }
}

Warning: Creates tight coupling. Use only for stable, shared concerns.


Backward-Compatible Event Evolution

Scenario: Products Adds New Event

Step 1: Products emits new event version

typescript.js
// Products App
eventBus.emit({
  event: 'cart:item-added',
  version: '2.0.0',  // New version
  data: {
    itemId: '123',
    productId: '456',
    quantity: 2,
    price: 29.99,
    discount: 5.00  // New field
  }
})

Step 2: Checkout handles both versions

typescript.js
// Checkout App
handleCartItemAdded(event: EventContract) {
  if (event.version === '1.0.0') {
    // Old version - ignore new fields
    return this.handleV1(event)
  } else if (event.version === '2.0.0') {
    // New version - use new fields
    return this.handleV2(event)
  }
}

Step 3: Products can still emit v1 (during transition)

typescript.js
// Products App - Emit both versions during transition
addItem(item: CartItem) {
  // Emit v1 (for old Checkout)
  eventBus.emit(CartEvents.itemAddedV1(item))
  
  // Emit v2 (for new Checkout)
  eventBus.emit(CartEvents.itemAddedV2(item))
}

Step 4: Once all apps updated, remove v1


What Breaks (And How to Prevent)

Breaking Change 1: Removed Field

typescript.js
// ❌ BREAKS: Removed field
interface CartItemAddedData {
  itemId: string
  // productId removed - Checkout breaks!
  quantity: number
  price: number
}

Prevention: Never remove fields. Mark as deprecated.

Breaking Change 2: Changed Field Type

typescript.js
// ❌ BREAKS: Changed type
interface CartItemAddedData {
  itemId: string
  price: string  // Was number - Checkout breaks!
}

Prevention: Use new field name. Support both during transition.

Breaking Change 3: Required Field

typescript.js
// ❌ BREAKS: Made required
interface CartItemAddedData {
  itemId: string
  discount: number  // Required now - old events break!
}

Prevention: New required fields must have defaults or be optional.


Notification System Example

The Problem

Products adds item. Checkout needs to show notification. Host needs to update cart badge.

The Solution: Typed Notification Events

typescript.js
// Shared notification contract
export interface NotificationEvent extends EventContract {
  event: 'notification:show'
  version: '1.0.0'
  data: {
    message: string
    type: 'success' | 'error' | 'info'
    duration?: number
  }
}
typescript.js
// Products App
addItem(item: CartItem) {
  // Add to cart
  this.cartService.addItem(item)
  
  // Show notification
  this.eventBus.emit({
    event: 'notification:show',
    version: '1.0.0',
    data: {
      message: 'Item added to cart',
      type: 'success',
      duration: 3000
    }
  })
}
typescript.js
// Host App - Shows notifications
export class NotificationComponent {
  constructor(private eventBus: EventBus) {
    this.eventBus.on('notification:show').subscribe(event => {
      if (event.version === '1.0.0') {
        this.showNotification(event.data)
      }
    })
  }
}

Testing Communication

Test Event Emission

typescript.js
describe('CartService', () => {
  it('should emit cart:item-added event', () => {
    const mockEventBus = jasmine.createSpyObj('EventBus', ['emit'])
    const service = new CartService(mockEventBus)
    
    service.addItem({ id: '1', name: 'Product', price: 10 })
    
    expect(mockEventBus.emit).toHaveBeenCalledWith(
      jasmine.objectContaining({
        event: 'cart:item-added',
        version: '1.0.0'
      })
    )
  })
})

Test Event Handling

typescript.js
describe('CheckoutEventHandler', () => {
  it('should handle v1 events', () => {
    const handler = new CheckoutEventHandler(mockEventBus)
    
    mockEventBus.emit({
      event: 'cart:item-added',
      version: '1.0.0',
      data: { itemId: '1', productId: '2', quantity: 1, price: 10 }
    })
    
    expect(handler.cartItems).toHaveLength(1)
  })
  
  it('should handle v2 events', () => {
    const handler = new CheckoutEventHandler(mockEventBus)
    
    mockEventBus.emit({
      event: 'cart:item-added',
      version: '2.0.0',
      data: { itemId: '1', productId: '2', quantity: 1, price: 10, discount: 2 }
    })
    
    expect(handler.cartItems).toHaveLength(1)
    expect(handler.cartItems[0].discount).toBe(2)
  })
})

Production Checklist

  • [ ] Event contracts defined and documented
  • [ ] Versioning strategy implemented
  • [ ] Backward compatibility ensured
  • [ ] Event evolution strategy defined
  • [ ] Error handling for failed events
  • [ ] Testing strategy for communication
  • [ ] Documentation for event contracts

Key Takeaways

  1. Communication is contracts - Not just events
  2. Version everything - Enable backward compatibility
  3. Additive changes only - Never remove fields
  4. Test independently - Mock event bus in tests
  5. Document contracts - Event structure is API

Remember: In micro frontends, communication patterns determine whether your system survives independent deployments.

The next module: What happens when things fail.