Module 16: State Management Across Micro Frontends (Production Architecture)
Chapter 16 • Advanced
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)
// 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:
- Version Conflicts
- Products team updates CartItem structure
- Checkout team hasn't updated yet
- Checkout breaks
- Deployment Coordination
- Products deploys new cart structure
- Checkout still expects old structure
- Production breaks
- State Ownership
- Who owns the cart? Host? Products? Checkout?
- What if Products and Checkout modify cart simultaneously?
- Race conditions
- 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.
// 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))
}
}
// 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.
// 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)
)
}
}
// 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'
})
}
}
// 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.
// 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])
}
}
// 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
// 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
// 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
// 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)
@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
// 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
}
// 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
// ❌ 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
// ❌ 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
// ❌ 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
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
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
- Shared state is dangerous - Prefer duplicate state with events
- Version everything - Enable backward compatibility
- Test independently - Each app tests its own state
- Assume failure - Storage can fail, events can be lost
- 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.