16

Module 16: State Management Across Micro Frontends (Production Architecture)

Chapter 16 • Advanced

50 min

State Management Across Micro Frontends (Production Architecture)

The Real Problem

Scenario: User adds product to cart in Products app. User navigates to Checkout app. Cart is empty.

Why? Products and Checkout are separate applications. They don't share memory.

The Question: How do I share a shopping cart between Products and Checkout without creating a distributed system nightmare?

This is not a tutorial. This is production architecture.


Why Global Shared State Is Dangerous

The Naive Approach (That Fails)

typescript.js
// Shared service in Host
@Injectable({ providedIn: 'root' })
export class CartService {
  private items: CartItem[] = []
  
  addItem(item: CartItem) {
    this.items.push(item)
  }
  
  getItems() {
    return this.items
  }
}

Why This Breaks:

  1. Version Conflicts
  • Products team updates CartItem structure
  • Checkout team hasn't updated yet
  • Checkout breaks
  1. Deployment Coordination
  • Products deploys new cart structure
  • Checkout still expects old structure
  • Production breaks
  1. State Ownership
  • Who owns the cart? Host? Products? Checkout?
  • What if Products and Checkout modify cart simultaneously?
  • Race conditions
  1. Testing Nightmare
  • Can't test Products without Checkout
  • Can't test Checkout without Products
  • Integration tests become required for everything

This is why shared state in micro frontends is dangerous.


The Production Reality

Our E-Commerce System

  • Products App (Team A) - Adds items to cart
  • Checkout App (Team B) - Displays cart, processes payment
  • Host App (Platform Team) - Orchestrates

Constraints:

  • Teams deploy independently
  • Different release cadences
  • No coordination possible
  • Network can fail
  • Apps can be down

The Question: How do we share cart state safely?


Strategy 1: Duplicate State Intentionally (Recommended)

The Pattern

Each app maintains its own copy of the cart. Apps communicate changes via events.

typescript.js
// Products App - Owns cart state
@Injectable({ providedIn: 'root' })
export class ProductsCartService {
  private items: CartItem[] = []
  
  addItem(item: CartItem) {
    this.items.push(item)
    // Persist to localStorage
    this.saveToStorage()
    // Notify other apps
    this.eventBus.emit('cart:item-added', item)
  }
  
  private saveToStorage() {
    localStorage.setItem('cart', JSON.stringify(this.items))
  }
}
typescript.js
// Checkout App - Reads cart from storage
@Injectable({ providedIn: 'root' })
export class CheckoutCartService {
  private items: CartItem[] = []
  
  constructor(private eventBus: EventBus) {
    // Load from storage on init
    this.loadFromStorage()
    // Listen for changes
    this.eventBus.on('cart:item-added').subscribe(item => {
      this.items.push(item)
      this.saveToStorage()
    })
  }
  
  private loadFromStorage() {
    const stored = localStorage.getItem('cart')
    if (stored) {
      this.items = JSON.parse(stored)
    }
  }
}

Why This Works

No Shared Memory - Each app has its own state

Version Tolerant - Apps can use different CartItem structures

Resilient - If Products is down, Checkout still has cart

Testable - Each app tests its own state

Independent Deployment - Teams deploy without coordination

When to Use

  • ✅ State can be duplicated safely
  • ✅ State changes are infrequent
  • ✅ Eventual consistency is acceptable
  • ✅ State is relatively small

When NOT to Use

  • ❌ State must be perfectly synchronized
  • ❌ State is large (performance issue)
  • ❌ State changes are very frequent (event storm)

Strategy 2: Event-Driven State (For Complex Scenarios)

The Pattern

Central event bus. Apps publish events. Apps subscribe to events. Each app maintains its own state.

typescript.js
// Shared Event Bus (in Host or separate package)
@Injectable({ providedIn: 'root' })
export class EventBus {
  private subject = new Subject<Event>()
  
  emit(event: string, data: any) {
    this.subject.next({ event, data, timestamp: Date.now() })
  }
  
  on(event: string): Observable<any> {
    return this.subject.pipe(
      filter(e => e.event === event),
      map(e => e.data)
    )
  }
}
typescript.js
// Products App
export class ProductsCartService {
  addItem(item: CartItem) {
    // Update local state
    this.items.push(item)
    // Emit event
    this.eventBus.emit('cart:item-added', {
      item,
      cartId: this.cartId,
      version: '1.0.0'
    })
  }
}
typescript.js
// Checkout App
export class CheckoutCartService {
  constructor(private eventBus: EventBus) {
    // Subscribe to cart events
    this.eventBus.on('cart:item-added').subscribe(event => {
      // Handle based on version
      if (event.version === '1.0.0') {
        this.handleItemAddedV1(event.item)
      } else if (event.version === '2.0.0') {
        this.handleItemAddedV2(event.item)
      }
    })
  }
}

Why This Works

Loose Coupling - Apps don't know about each other

Version Tolerance - Events can include version info

Backward Compatible - Old apps can ignore new events

Testable - Mock event bus in tests

Trade-Offs

⚠️ Event Ordering - Events might arrive out of order

⚠️ Event Loss - If app is down, events are lost

⚠️ Complexity - More moving parts


Strategy 3: Centralized Store (Use Sparingly)

The Pattern

Single source of truth in Host. Remotes read/write via API.

typescript.js
// Host App - Owns cart state
@Injectable({ providedIn: 'root' })
export class CartStore {
  private items$ = new BehaviorSubject<CartItem[]>([])
  
  getItems(): Observable<CartItem[]> {
    return this.items$.asObservable()
  }
  
  addItem(item: CartItem) {
    const current = this.items$.value
    this.items$.next([...current, item])
  }
}
typescript.js
// Products App - Uses cart store
export class ProductsComponent {
  cartItems$ = this.cartStore.getItems()
  
  addToCart(product: Product) {
    this.cartStore.addItem({
      id: product.id,
      name: product.name,
      price: product.price
    })
  }
}

When to Use

  • ✅ State must be perfectly synchronized
  • ✅ Single source of truth required
  • ✅ State is complex (not just cart)

When NOT to Use

  • ❌ Teams need independence
  • ❌ Different release cadences
  • ❌ State structure changes frequently

Warning: This creates tight coupling. Use only when necessary.


The Cart Example: Production Implementation

Step 1: Define Cart Contract

typescript.js
// Shared types package (or in Host)
export interface CartItem {
  id: string
  name: string
  price: number
  quantity: number
  version: '1.0.0' // Version for backward compatibility
}

Step 2: Products App Implementation

typescript.js
// products-app/src/app/cart/cart.service.ts
@Injectable({ providedIn: 'root' })
export class CartService {
  private items: CartItem[] = []
  
  constructor(
    private eventBus: EventBus,
    private storage: StorageService
  ) {
    this.loadFromStorage()
  }
  
  addItem(item: CartItem) {
    // Update local state
    this.items.push(item)
    
    // Persist
    this.storage.set('cart', this.items)
    
    // Notify
    this.eventBus.emit('cart:updated', {
      items: this.items,
      version: '1.0.0'
    })
  }
  
  getItems(): CartItem[] {
    return [...this.items]
  }
  
  private loadFromStorage() {
    const stored = this.storage.get('cart')
    if (stored) {
      this.items = stored
    }
  }
}

Step 3: Checkout App Implementation

typescript.js
// checkout-app/src/app/cart/cart.service.ts
@Injectable({ providedIn: 'root' })
export class CartService {
  private items: CartItem[] = []
  items$ = new BehaviorSubject<CartItem[]>([])
  
  constructor(
    private eventBus: EventBus,
    private storage: StorageService
  ) {
    this.loadFromStorage()
    this.listenForUpdates()
  }
  
  private listenForUpdates() {
    // Listen for cart updates
    this.eventBus.on('cart:updated').subscribe(event => {
      if (event.version === '1.0.0') {
        this.items = event.items
        this.storage.set('cart', this.items)
        this.items$.next(this.items)
      }
    })
    
    // Also poll storage (in case event missed)
    setInterval(() => {
      const stored = this.storage.get('cart')
      if (JSON.stringify(stored) !== JSON.stringify(this.items)) {
        this.items = stored || []
        this.items$.next(this.items)
      }
    }, 1000)
  }
  
  private loadFromStorage() {
    const stored = this.storage.get('cart')
    if (stored) {
      this.items = stored
      this.items$.next(this.items)
    }
  }
}

Why This Implementation Works

Resilient - Storage fallback if events fail

Version Tolerant - Version checking prevents breaks

Independent - Apps can work standalone

Testable - Each app tests independently


State Persistence Strategy

localStorage (Recommended for Cart)

typescript.js
@Injectable({ providedIn: 'root' })
export class StorageService {
  set(key: string, value: any) {
    try {
      localStorage.setItem(key, JSON.stringify(value))
    } catch (e) {
      console.error('Storage failed', e)
    }
  }
  
  get(key: string): any {
    try {
      const item = localStorage.getItem(key)
      return item ? JSON.parse(item) : null
    } catch (e) {
      return null
    }
  }
}

Why localStorage:

  • ✅ Works across apps (same origin)
  • ✅ Persists across sessions
  • ✅ Fast
  • ✅ No server required

Limitations:

  • ❌ Size limit (~5MB)
  • ❌ Same origin only
  • ❌ Not secure (don't store sensitive data)

Version Tolerance Pattern

The Problem

Products team updates CartItem structure. Checkout team hasn't updated yet.

The Solution

typescript.js
// Version 1.0.0
interface CartItem {
  id: string
  name: string
  price: number
}

// Version 2.0.0 (adds discount)
interface CartItem {
  id: string
  name: string
  price: number
  discount?: number  // Optional for backward compatibility
}
typescript.js
// Checkout handles both versions
handleCartItem(item: any) {
  if (item.version === '1.0.0') {
    // Handle old format
    return {
      id: item.id,
      name: item.name,
      price: item.price,
      discount: 0  // Default for old items
    }
  } else {
    // Handle new format
    return item
  }
}

Key Principle: New versions must be backward compatible.


What Breaks (And How to Prevent)

Breaking Change 1: Structure Change

typescript.js
// ❌ BREAKS: Removed field
interface CartItem {
  id: string
  // name removed - Checkout breaks!
  price: number
}

Prevention: Never remove fields. Mark as deprecated, keep in structure.

Breaking Change 2: Type Change

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

Prevention: Use versioning. Support both types during transition.

Breaking Change 3: Required Field

typescript.js
// ❌ BREAKS: Made required
interface CartItem {
  id: string
  name: string
  discount: number  // Required now - old items break!
}

Prevention: New required fields must have defaults.


Decision Framework

Choose Duplicate State If:

  • ✅ State can be duplicated safely
  • ✅ Eventual consistency OK
  • ✅ Teams need independence
  • ✅ State is small-medium

Choose Event-Driven If:

  • ✅ State is complex
  • ✅ Multiple apps need updates
  • ✅ Real-time updates needed
  • ✅ Can handle eventual consistency

Choose Centralized Store If:

  • ✅ Perfect synchronization required
  • ✅ Single source of truth critical
  • ✅ Teams can coordinate
  • ✅ State is complex

Testing Strategy

Test Products Cart Independently

typescript.js
describe('ProductsCartService', () => {
  it('should add item to cart', () => {
    const service = new ProductsCartService(mockEventBus, mockStorage)
    service.addItem({ id: '1', name: 'Product', price: 10 })
    expect(service.getItems()).toHaveLength(1)
  })
})

Test Checkout Cart Independently

typescript.js
describe('CheckoutCartService', () => {
  it('should load cart from storage', () => {
    mockStorage.set('cart', [{ id: '1', name: 'Product', price: 10 }])
    const service = new CheckoutCartService(mockEventBus, mockStorage)
    expect(service.items$.value).toHaveLength(1)
  })
})

Key: Each app tests independently. No integration tests required for basic functionality.


Production Checklist

  • [ ] State strategy chosen based on requirements
  • [ ] Version tolerance implemented
  • [ ] Backward compatibility ensured
  • [ ] Persistence strategy chosen
  • [ ] Error handling for storage failures
  • [ ] Testing strategy defined
  • [ ] Documentation for state contracts

Key Takeaways

  1. Shared state is dangerous - Prefer duplicate state with events
  2. Version everything - Enable backward compatibility
  3. Test independently - Each app tests its own state
  4. Assume failure - Storage can fail, events can be lost
  5. Document contracts - State structure is a contract

Remember: In micro frontends, state management is architecture, not just code.

The next module: Communication patterns that won't break when teams deploy independently.