Skip to content
ruijadom.SCALABLE SYSTEMS & EXPLORATIONDev Space
All field notes

Versioned Contracts Between Micro-Frontends

Once micro-frontends deploy independently, the hard part is no longer loading them but letting them talk. This guide compares the ways micro-frontends share state, explains why a shared store breaks at runtime, and builds a typed event bus with a versioned contract that survives independent deploys.

Rui Domingues

Rui Domingues

@ruijadom

This article continues Micro-Frontends: Scaling Teams and Codebases. If you have not read it yet, start there: it covers when micro-frontends make sense and how to set them up with Vite and Module Federation.

In the previous article we split an application into pieces that different teams build and deploy on their own. It stopped at the point where most real projects start to hurt: the pieces need to talk to each other.

A customer places an order in the checkout. The cart needs to empty itself. The loyalty widget needs to show the points they just earned. The order history needs to refresh. Four teams, four deploy pipelines, one fact: an order was placed.

There are several ways to share that fact, and they are often presented as a choice between "state management" and "an event bus". That framing misses the real question. Every option creates coupling. What changes is what the teams are coupled to, and what happens on the day one of them deploys and the others do not.

This guide walks through the options from the most coupled to the least, explains why a shared store tends to fail at runtime rather than at build time, and then builds an event bus with a versioned contract: typed at compile time, validated at runtime, and able to carry two versions of the same event while teams migrate at their own pace.

The Question Behind the Question

Independent deployment is the whole point of micro-frontends. It is also what makes communication hard. In a monolith, if you rename a field, the compiler finds every reader and the change ships in one deploy. With micro-frontends, the producer and the consumer are compiled separately, against whatever version of the shared types each one had at build time, and they meet for the first time in the user's browser.

TypeScript only protects you against the version of the types you built against. A micro-frontend deployed yesterday and one deployed today can disagree about the shape of the same object, and both will compile cleanly.

So the useful question is not "which state library?" but:

  • Who owns this data? One domain should own each piece of data. Everyone else reads it or reacts to it.
  • What is the unit of agreement between teams? A shape in memory, a URL, an API response, or a message?
  • How does that agreement change over time? If the answer is "everyone deploys together", you have built a distributed monolith.

The Spectrum of Options

Loading diagram...

1. A Shared Global Store

The first idea most teams have is to put a store in the host and expose it through Module Federation, so every micro-frontend can read and write it.

// host/src/stores/checkout-store.ts (exposed as "host/checkoutStore")
import { create } from 'zustand'
 
interface CheckoutState {
  lastOrder: { id: string; customerId: string } | null
  setLastOrder: (order: CheckoutState['lastOrder']) => void
}
 
export const useCheckoutStore = create<CheckoutState>()((set) => ({
  lastOrder: null,
  setLastOrder: (lastOrder) => set({ lastOrder }),
}))
// loyalty/src/PointsBanner.tsx
import { useCheckoutStore } from 'host/checkoutStore'
 
export function PointsBanner() {
  const customerId = useCheckoutStore((s) => s.lastOrder?.customerId)
  return customerId ? <PointsEarned customerId={customerId} /> : null
}

It works on day one, and it is the hardest option to change later. The shape of lastOrder is now a public API, read inside the render functions of other teams' components. If the checkout team renames customerId to customer.id, the loyalty widget compiles fine (it still has the old types) and shows an empty banner in production. Every consumer is coupled to the producer's internal data model, not to a message the producer chose to publish.

There are also mechanical problems. The store only works if every micro-frontend gets the same instance, so zustand and the store module must be federation singletons. Any team can write to it, so ownership of the data is a convention, not a rule. And debugging "who set this to null?" across four repositories is not fun.

A shared store is reasonable inside one domain, when a single team owns several micro-frontends that deploy together. Across domains, it is the micro-frontend version of every module importing every other module's internals.

2. Props and Context from the Host

The host can own the shared data and pass it down to each remote, as props on the mounted component or through a React context it provides.

This is more explicit than a shared store: each remote declares what it receives, and only the host writes. The cost is that the host becomes a coordinator that knows about every domain. Every new piece of shared data is a change to the host, and the host team becomes a bottleneck, which is exactly what micro-frontends were meant to remove. It also only works when the host and the remotes use the same framework.

Host props are a good fit for context that really is global: the signed-in user, the tenant, the locale, the feature flags. They are a poor fit for facts that one domain produces and another reacts to.

3. The URL

The URL is the oldest shared state on the web, and it is underrated. If the account area should show a specific order, /account/orders/123 is a contract every micro-frontend understands, survives a page reload, and can be shared as a link.

The URL is great for where the user is. It is not meant for things that happen while the user stays on the same page, like an order placed from a quick-buy button.

4. Server State

Often the data two micro-frontends "share" is really data that lives on the server. The order history, the product catalogue and the customer profile all have an owner already: the API.

If each micro-frontend fetches what it needs with its own React Query client, they stay consistent because they read from the same source, not because they share memory. The contract between teams is the API specification, which is already versioned, documented and reviewed.

The gap is freshness. When the checkout cancels an order, the order history in another micro-frontend does not know it should refetch. That is a perfect job for a small message, which brings us to the last option.

5. An Event Bus

With an event bus, a micro-frontend publishes a fact ("order 42 was placed") and anyone interested reacts to it. The producer does not know who is listening. The consumers do not know who produced it. The only thing they share is the definition of the event.

That definition is the whole design. An untyped bus, where anyone can dispatch('orderPlaced', anything), has the same problems as the shared store, with less visibility. A bus with a versioned contract is what makes independent deploys safe.

Comparing Them

Shared storeHost propsURLServer stateEvent bus + contract
Teams are coupled tothe producer's memory shapethe hostthe route structurethe API specthe event contract
Breaks atruntime, silentlyhost deploysbroken linksAPI versioningcontract validation, loudly
Who can writeanyonethe hostanyone (navigation)the API ownerthe event's owner
Works across frameworksnorarelyyesyesyes
Works across tabs and windowsnonoper tabyeswith a transport adapter
Best forone domain, one teamuser, tenant, flagswhere the user isdata the server ownsfacts other domains react to

The pragmatic answer is a combination: the URL for navigation, server state for data, host props for global context, and events for facts that cross domain boundaries. A shared store across domains is the one option worth avoiding.

Designing the Contract

The rest of this guide builds the event bus. It has three parts:

  1. A contract package, published on its own, that defines every event, every version of it, and how to convert between versions.
  2. A small bus that publishes and subscribes through the contract.
  3. Tests that run in the contract package and in every consumer.
Loading diagram...

The contract lives in its own package, not in the host and not in the producer. The host should not be a coordinator, and putting the definition inside the producer means consumers depend on the producer's repository. A separate package with its own changelog makes changes to it visible, reviewable and deliberate.

Events Describe Facts, Not Commands

Before writing any code, name the events well. An event says something that already happened in the producer's domain, in the past tense: order.placed, order.cancelled, cart.cleared.

Avoid two common mistakes:

  • UI events, like buyButton.clicked. They leak the producer's interface into other teams' code. When the button moves into a menu, the event changes meaning.
  • Commands, like cart.clear. A command tells another domain what to do, and the producer will soon want to know whether it worked. That is a function call over a bus, with none of the guarantees of a function call. If one micro-frontend needs another to do something, expose a function, a route or an API.

Schemas Per Version

Each event has one schema per version. Zod gives us both the runtime validator and the TypeScript type from one definition.

// packages/events-contract/src/contract.ts
import { z } from 'zod'
 
const OrderPlacedV1 = z.object({
  orderId: z.string(),
  customerId: z.string(),
})
 
const OrderPlacedV2 = z.object({
  orderId: z.string(),
  customer: z.object({
    id: z.string(),
    tier: z.string().optional(),
  }),
  channel: z.enum(['web', 'mobile']),
})
 
const OrderCancelledV1 = z.object({
  orderId: z.string(),
  reason: z.enum(['customer', 'payment_failed', 'out_of_stock']),
  refundCents: z.number().int().nonnegative(),
})
 
export const contract = {
  'order.placed': { 1: OrderPlacedV1, 2: OrderPlacedV2 },
  'order.cancelled': { 1: OrderCancelledV1 },
} as const

The types are derived from the contract, so there is no second definition to keep in sync.

// packages/events-contract/src/types.ts
import type { z } from 'zod'
import type { contract } from './contract'
 
export type Contract = typeof contract
export type EventType = keyof Contract
export type EventVersion<T extends EventType> = keyof Contract[T] & number
 
export type Payload<T extends EventType, V extends EventVersion<T>> =
  Contract[T][V] extends z.ZodTypeAny ? z.infer<Contract[T][V]> : never
 
export interface Envelope<T extends EventType, V extends EventVersion<T>> {
  id: string
  type: T
  version: V
  source: string
  timestamp: number
  correlationId?: string
  payload: Payload<T, V>
}

The envelope carries what every event needs regardless of its type: a unique id, the source micro-frontend for debugging, a timestamp, and an optional correlationId to follow one user action across several events.

Converters Between Versions

Version 2 of order.placed replaced customerId with a customer object and added channel. Some consumers will still expect version 1 for weeks. Instead of asking every team to migrate on the same day, the contract package knows how to turn one version into the next and back.

// packages/events-contract/src/converters.ts
import type { EventType, Payload } from './types'
 
type ConverterTable = Partial<
  Record<EventType, Record<number, (payload: any) => unknown>>
>
 
// upcasters[type][n] turns version n into version n + 1
export const upcasters: ConverterTable = {
  'order.placed': {
    1: (p: Payload<'order.placed', 1>): Payload<'order.placed', 2> => ({
      orderId: p.orderId,
      customer: { id: p.customerId },
      // Version 1 was only ever published by the web checkout.
      channel: 'web',
    }),
  },
}
 
// downcasters[type][n] turns version n into version n - 1
export const downcasters: ConverterTable = {
  'order.placed': {
    2: (p: Payload<'order.placed', 2>): Payload<'order.placed', 1> => ({
      orderId: p.orderId,
      customerId: p.customer.id,
    }),
  },
}

Writing the converters is a good test of the change itself. Look at the upcaster: it has to invent channel. That is only acceptable because we know version 1 was only published by the web checkout. If a converter would have to guess, the change is not a new version of the same event. It is a different event, and it deserves a different name.

The rule of thumb: the producer owns compatibility going back (downcasting to versions older consumers still read), and the consumer owns compatibility going forward (upcasting from versions older producers still publish). Both use the converters in their own copy of the contract package.

Building the Bus

The bus needs very little code. The browser already has an event system: EventTarget and CustomEvent. What we add is the contract.

One Bus Per Page

With Module Federation, it is easy to end up with two copies of the same package on one page, one bundled in each micro-frontend. Two copies of a module-level new EventTarget() would be two buses that never hear each other. To avoid depending on singleton configuration, the bus lives on globalThis under a registered symbol, so every copy of the package finds the same one.

// packages/events-contract/src/bus.ts
import type { z } from 'zod'
import { contract } from './contract'
import { downcasters, upcasters } from './converters'
import type { Envelope, EventType, EventVersion, Payload } from './types'
 
const BUS_KEY = Symbol.for('acme.events.bus')
 
function bus(): EventTarget {
  const g = globalThis as Record<symbol, unknown>
  return (g[BUS_KEY] ??= new EventTarget()) as EventTarget
}
 
const channel = (type: string, version: number) => `${type}@${version}`
 
function schemaFor(type: EventType, version: number) {
  return (contract[type] as Record<number, z.ZodTypeAny>)[version]
}

Each version of each event gets its own channel name, such as order.placed@2. A consumer only listens to the versions it understands.

Publishing

The producer validates its own payload first. This catches producer bugs at the source, in development and in the producer's tests, instead of in someone else's handler.

It then publishes the event at its own version, and again at every older version it can still produce through the downcasters, newest first, all with the same id.

export function publish<T extends EventType, V extends EventVersion<T>>(
  type: T,
  version: V,
  payload: Payload<T, V>,
  meta: { source: string; correlationId?: string },
): void {
  const id = crypto.randomUUID()
  const timestamp = Date.now()
 
  let current: unknown = schemaFor(type, version).parse(payload)
 
  for (let v: number = version; current !== undefined; v--) {
    bus().dispatchEvent(
      new CustomEvent(channel(type, v), {
        detail: { id, type, version: v, timestamp, ...meta, payload: current },
      }),
    )
    const down = downcasters[type]?.[v]
    current = down ? down(current) : undefined
  }
}

Subscribing

A consumer subscribes to the version it was written for. The bus also listens to every older version the consumer's copy of the contract knows, and upcasts those. That covers the case where the consumer was deployed before the producer.

Because the producer publishes the newest version first and with the same id, a consumer that already handled the richer version ignores the older copies. If the newer copy fails validation, the consumer still accepts an older one, so a bad payload in a new version degrades instead of breaking the feature.

export function subscribe<T extends EventType, V extends EventVersion<T>>(
  type: T,
  version: V,
  handler: (event: Envelope<T, V>) => void,
): () => void {
  const target = schemaFor(type, version)
  // All versions of one event are dispatched back to back, so the last id is enough.
  let lastHandledId: string | undefined
  const listeners: Array<[string, EventListener]> = []
 
  for (let from: number = version; schemaFor(type, from); from--) {
    const listener: EventListener = (event) => {
      const envelope = (event as CustomEvent).detail
      if (envelope.id === lastHandledId) return
 
      let payload: unknown = envelope.payload
      for (let v = from; v < version; v++) {
        payload = upcasters[type]![v]!(payload)
      }
 
      const result = target.safeParse(payload)
      if (!result.success) {
        reportContractViolation(envelope, result.error)
        return
      }
 
      lastHandledId = envelope.id
      handler({ ...envelope, version, payload: result.data } as Envelope<T, V>)
    }
 
    bus().addEventListener(channel(type, from), listener)
    listeners.push([channel(type, from), listener])
  }
 
  registerSubscriber(type, version)
 
  return () => {
    for (const [name, listener] of listeners) {
      bus().removeEventListener(name, listener)
    }
  }
}

Validation on the consumer side matters as much as on the producer side. TypeScript told the consumer's developer what the payload looks like at build time. Zod checks what it actually looks like today, after a deploy the consumer's team never saw. When the check fails, reportContractViolation logs it in development and sends it to monitoring in production, and the handler is not called with data it cannot handle.

Note that z.object drops keys it does not know. That is deliberate: a consumer built against an older schema simply ignores fields that were added later. This is the "tolerant reader" pattern, and it is what makes additive changes free. Do not use .strict() on event schemas.

A React Hook

Most consumers are React components, so a small hook makes subscribing safe with respect to re-renders and unmounts.

// packages/events-contract/src/react.ts
import { useEffect, useLayoutEffect, useRef } from 'react'
import { subscribe } from './bus'
import type { Envelope, EventType, EventVersion } from './types'
 
export function useEvent<T extends EventType, V extends EventVersion<T>>(
  type: T,
  version: V,
  handler: (event: Envelope<T, V>) => void,
) {
  const handlerRef = useRef(handler)
  useLayoutEffect(() => {
    handlerRef.current = handler
  })
 
  useEffect(
    () => subscribe(type, version, (event) => handlerRef.current(event)),
    [type, version],
  )
}

Now the loyalty team writes code against version 2, fully typed:

// loyalty/src/useShowPointsOnOrder.ts
import { useEvent } from '@acme/events-contract/react'
 
export function useShowPointsOnOrder() {
  const showPointsEarned = useShowPointsEarned()
 
  useEvent('order.placed', 2, ({ payload }) => {
    showPointsEarned({
      customerId: payload.customer.id,
      tier: payload.customer.tier,
    })
  })
}

And the history team, still on version 1 of order.cancelled, uses the event only as a signal to refetch. This is where the event bus and server state work together: the event says something changed, and React Query fetches what changed from the source of truth.

// order-history/src/useRefreshHistoryOnCancel.ts
import { useQueryClient } from '@tanstack/react-query'
import { useEvent } from '@acme/events-contract/react'
 
export function useRefreshHistoryOnCancel() {
  const queryClient = useQueryClient()
 
  useEvent('order.cancelled', 1, () => {
    queryClient.invalidateQueries({ queryKey: ['order-history'] })
  })
}

Keep payloads small. An event should carry identifiers and the few fields a consumer needs to react, not a copy of the producer's state. If a consumer needs the full record, it fetches it. Small payloads are easier to version and almost never need a new major version.

Versioning Rules

A contract is only useful if everyone agrees on what counts as a breaking change. These rules fit on one page and should live in the contract package's README.

ChangeEvent versionPackage version
Add an optional fieldstays the sameminor
Add a new eventstarts at 1minor
Rename or remove a field, change a type, make a field requirednew version with converters both waysminor
Stop supporting the oldest version of an eventremoved from the contractmajor

Two details are easy to miss.

A new event version is not a breaking change for the package. Old consumers keep working, because producers keep publishing the old version through the downcaster. The package only has a breaking release when a version is removed.

Removing a version needs data, not a guess. The registerSubscriber call in subscribe is there for this. It records which micro-frontend subscribes to which version and reports it to your analytics. When no deployed micro-frontend has subscribed to order.placed@1 for a few weeks, it is safe to delete version 1 and its converters in a major release.

function registerSubscriber(type: EventType, version: number) {
  const source = currentMicroFrontendName()
  telemetry.track('event_subscription', { type, version, source })
}

Testing the Contract

Three kinds of tests keep the contract honest, and none of them need a browser.

In the Contract Package

Each version of each event ships with an example payload. The contract's own tests check that every example matches its schema, and that every converter turns a valid payload into a valid payload of the neighbouring version.

// packages/events-contract/src/contract.test.ts
import { describe, expect, it } from 'vitest'
import type { z } from 'zod'
import { contract } from './contract'
import { downcasters, upcasters } from './converters'
import { examples } from './examples'
import type { EventType } from './types'
 
const types = Object.keys(contract) as EventType[]
 
describe.each(types)('%s', (type) => {
  const schemas = contract[type] as Record<number, z.ZodTypeAny>
  const versions = Object.keys(schemas).map(Number).sort((a, b) => a - b)
  const example = (v: number) => (examples[type] as Record<number, unknown>)[v]
 
  it.each(versions)('example for v%i matches its schema', (v) => {
    expect(schemas[v].safeParse(example(v)).success).toBe(true)
  })
 
  it.each(versions.slice(0, -1))('v%i upcasts to the next version', (v) => {
    const up = upcasters[type]?.[v]
    expect(up).toBeDefined()
    expect(schemas[v + 1].safeParse(up!(example(v))).success).toBe(true)
  })
 
  it.each(versions.slice(1))('v%i downcasts to the previous version', (v) => {
    const down = downcasters[type]?.[v]
    expect(down).toBeDefined()
    expect(schemas[v - 1].safeParse(down!(example(v))).success).toBe(true)
  })
})

This makes it impossible to merge a new version without the converters that keep older micro-frontends working.

In Each Consumer

Each consumer tests its handlers against the published examples of the versions it subscribes to. When the contract package is upgraded in the consumer's repository, these tests show whether the consumer still understands what producers send. This is the idea behind consumer-driven contract testing, as tools like Pact do for HTTP APIs, applied to events in the browser.

In Each Producer

The producer's tests call the code that publishes and check that publish does not throw. Because publish validates the payload, a producer cannot send something its own contract does not allow.

Beyond One Page

Users rarely keep an application in one tab. A customer might browse the catalogue in one tab and check out in another, and a desktop app built with Electron can have several windows. The bus above only covers one JavaScript realm, but the envelope was designed to travel.

Because envelopes are plain data, with no functions and no class instances, they survive the structured clone algorithm. That means a transport adapter can forward them through BroadcastChannel between tabs, or through Electron IPC between windows, and the receiving side dispatches them on its local bus. The contract, the converters and the validation stay the same. Only the transport changes.

The one thing an adapter must add is a guard against echoes: mark envelopes that arrived from another realm, and do not forward those back out.

Common Misconceptions

  • "An event bus decouples the micro-frontends." It changes what they are coupled to. Without a contract, they are coupled to strings and guesses, which is worse than a shared store because nothing shows the dependency.
  • "TypeScript makes the contract safe." It makes it safe at build time, for the version each team built against. Independent deploys need runtime validation too.
  • "We will just update every consumer when an event changes." That is a coordinated deploy, which is what micro-frontends exist to avoid. Converters let teams migrate on their own schedule.
  • "Everything shared should go through the bus." Most shared data belongs to the server, and the URL already shares navigation. The bus is for facts that cross domains.
  • "A shared store is fine because we use Module Federation singletons." Singletons solve the "same instance" problem. They do nothing for the "different shape after a deploy" problem.

Conclusion

Communication between micro-frontends is an API design problem, not a state-library problem. Every option couples teams to something: a shape in memory, the host, the routes, the server API, or a contract. The goal is to choose the coupling that survives independent deploys.

For most applications that means using the URL for navigation, server state for data, host props for truly global context, and an event bus for facts one domain produces and others react to. The bus is only as good as its contract: events named as facts, a schema per version, converters in both directions, validation on both sides, and tests that refuse a new version without a path for older consumers.

Treat events with the same care as a public REST API, with a specification, versions, a deprecation policy and usage data, and they will let your teams change their own code without asking anyone else for permission. That freedom was the reason to split the frontend in the first place.