Micro Frontend Governance in Practice — Automating Module Federation Contracts, Bundles, and Ownership
The first few months after adopting Micro Frontends (MFE) feel genuinely great. Each team deploys independently, technology stacks are flexible, and nobody seems to be blocking anyone else. I thought this was the answer at first. Then one day a message like this shows up in Slack: "The payments team upgraded to React 18 — why is our app broken?"
Once an MFE architecture reaches scale, a second problem inevitably emerges: governance. When every team has complete autonomy, you get dependency conflicts, UI fragmentation, and bundle bloat. But if central control manages everything, the entire reason you adopted MFE disappears. This article explores how to find that balance using real tools and code.
The core thesis here is that internalizing the three boundaries — contracts, bundles, and ownership — through automation lets you operate a large-scale distributed frontend reliably without touching team autonomy.
Core Concepts
Governance Is Ultimately "Automated Boundaries"
Honestly, many teams start by saying "let's create a governance committee" and fail. Having people review things manually becomes a bottleneck as teams grow, and eventually everyone finds ways to bypass the gatekeeper. The platform team should act as an infrastructure provider, not a gatekeeper, and governance rules should be embedded in build pipelines, type systems, and lint rules rather than in review boards.
Governance = Human Process × 0 + Automation × n When a team violates a contract, the build breaks. When a bundle exceeds the budget, the PR is blocked. Rules are enforced by tools, not remembered by people.
Centered on this principle, let's walk through four pillars that have proven effective in practice — contracts → shared dependencies → bundle budgets → ownership visibility — covering both concepts and code together.
Contracts: The Only Official Language Between Teams
The first pillar of MFE governance is the boundary contract — explicitly defining what a Remote module exposes and what a Host expects.
In a Module Federation setup, a Remote is the app that exposes functionality, and a Host is the app that consumes it. Below is a Module Federation 3.0 configuration example where the Remote automatically generates and publishes types, and the Host automatically consumes them.
// apps/payment/webpack.config.ts (Remote side — generates and publishes types)
import { ModuleFederationPlugin } from '@module-federation/enhanced';
export default {
plugins: [
new ModuleFederationPlugin({
name: 'payment',
filename: 'remoteEntry.js',
exposes: {
'./Checkout': './src/components/Checkout',
'./usePaymentStatus': './src/hooks/usePaymentStatus',
},
dts: {
generateTypes: true,
tsConfigPath: './tsconfig.json',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
},
}),
],
};// apps/shell/webpack.config.ts (Host side — automatically downloads Remote types)
import { ModuleFederationPlugin } from '@module-federation/enhanced';
export default {
plugins: [
new ModuleFederationPlugin({
name: 'shell',
remotes: {
payment: 'payment@https://payment.example.com/remoteEntry.js',
},
dts: {
consumeTypes: true, // No need to manually duplicate Remote type declarations
},
}),
],
};Thanks to the dts option in Module Federation 3.0, the era of Host teams manually copy-pasting Remote interfaces is over. When a Remote changes its types, the Host's build produces a compile error. Runtime bombs are moved forward to the static analysis stage. The first time I enabled this option in production, my immediate reaction was "why didn't we use this sooner?"
Shared Dependency Policy: Singleton or Not
I was confused about this at first too — the most common conflicts in MFE arise from shared dependency issues. If each Remote bundles React separately, multiple React instances end up on the page and hooks break. If you share React, you get version lock-in.
| Strategy | When to Use | Caveats |
|---|---|---|
singleton: true |
When only one instance is acceptable, like React and React-DOM | Requires version negotiation; one team's upgrade affects everyone |
singleton: false |
Utility libraries, independently executable cases | Duplicate bundles → increased page size |
| Externalized + CDN | Stable major-version libraries | CDN outage becomes a single point of failure |
This policy must be explicitly decided before introducing Module Federation. Changing it later means touching every Remote configuration, which turns out to be surprisingly painful.
Contract Testing: CI Catches Contract Violations First
This is a situation I frequently encounter in practice — a Remote quietly changes its interface while the API documentation stays the same. Connecting Pact-based contract testing to CI lets you block these situations in advance.
What is Contract Testing? A testing approach where the consumer and provider independently verify an agreed-upon interface specification. It's faster than E2E tests and can confirm contract validity without both sides deploying simultaneously.
The example below shows the shell (Host) verifying the contract of the HTTP API called internally by the payment Remote's Checkout component. Since Pact is a consumer-provider contract tool for the HTTP layer, it guarantees the API response format that the component depends on — not the component's prop types.
// payment-remote.pact.spec.ts (Consumer-side contract definition)
// This test verifies the response contract of the
// /api/order/:id endpoint called internally by the Checkout component.
import { Pact } from '@pact-foundation/pact';
const provider = new Pact({
consumer: 'shell',
provider: 'payment',
port: 4000,
});
describe('payment/Checkout — Internal API Contract', () => {
beforeAll(() => provider.setup());
afterAll(() => provider.finalize());
it('should be able to retrieve order information by orderId', async () => {
await provider.addInteraction({
state: 'a valid order exists',
uponReceiving: 'request to fetch order details by orderId',
withRequest: {
method: 'GET',
path: '/api/order/abc-123',
},
willRespondWith: {
status: 200,
body: { orderId: 'abc-123', amount: 15000, currency: 'KRW' },
},
});
// ... Render Checkout component and verify API call result
});
});There is a real adoption barrier. Initial setup including Pact Broker configuration can take more than a day. But once you experience a Host silently breaking right after a Remote deployment, no further persuasion is needed.
Practical Application
Example 1: Automating Performance Governance with a Bundle Size CI Gate
Without per-team bundle size limits, each MFE independently grows heavier and overall page performance degrades cumulatively. Connecting Bundlemon to CI lets you automatically block PRs that exceed bundle limits.
# .github/workflows/bundle-governance.yml
name: Bundle Size Gate
on: [pull_request]
jobs:
check-bundle:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- name: Build
run: pnpm build
- name: Validate bundle size
uses: nickvdyck/bundlemon-action@v1
with:
files: |
[
{ "path": "dist/remoteEntry.js", "maxSize": "50kb" },
{ "path": "dist/assets/*.js", "maxSize": "200kb", "compression": "gzip" }
]
env:
BUNDLEMON_PROJECT_ID: ${{ secrets.BUNDLEMON_PROJECT_ID }}| Config Field | Role |
|---|---|
maxSize |
Maximum allowed bundle size. PR is blocked if exceeded |
compression: "gzip" |
Measures based on actual transfer size |
BUNDLEMON_PROJECT_ID |
Manage per-team limits separately by project |
With this single gate, CI takes over the retrospective time spent asking "why did our team's bundle get so large?" If you're unsure what baseline to use, starting with your current size plus 20% headroom works fine.
Example 2: Visualizing MFE Ownership and Dependencies with Backstage
As teams grow, the time spent searching Slack for "which teams are depending on this Remote?" accumulates. When each MFE adds a single catalog-info.yaml to its repository, Backstage automatically indexes the owning team, dependencies, and status, making everything searchable through the portal.
# apps/payment/catalog-info.yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: payment-mfe
description: Payment flow micro frontend
tags:
- micro-frontend
- react
annotations:
backstage.io/techdocs-ref: dir:.
mfe/exposed-modules: 'Checkout, usePaymentStatus'
mfe/singleton-deps: 'react@^18.0.0, react-dom@^18.0.0'
mfe/bundle-budget: '50kb'
spec:
type: micro-frontend
lifecycle: production
owner: team-payments
dependsOn:
- component:order-mfe
- component:design-system
providesApis:
- payment-checkout-api# catalog-info.yaml — API contract declaration
apiVersion: backstage.io/v1alpha1
kind: API
metadata:
name: payment-checkout-api
spec:
type: openapi
lifecycle: production
owner: team-payments
definition:
$text: ./openapi/checkout.yamlCustom annotations like mfe/exposed-modules and mfe/bundle-budget are MFE-specific metadata. Having used this in practice, the speed at which you can assess blast radius by checking "which teams depend on this Remote?" directly in the portal is noticeably different.
If setting up Backstage feels daunting, starting by just keeping the YAML file in your repository is a valid first step. Beginning with Markdown without any infrastructure, then attaching Backstage when the scale warrants it, is a perfectly realistic approach.
Example 3: Centralized Singleton Version Control with Import Maps
If Module Federation feels too heavy for your environment, using browser-native Import Maps as a governance layer is another option. You can centrally control module resolution paths at the browser level without any external build tooling.
<!-- index.html — Import Map managed by the platform team -->
<script type="importmap">
{
"imports": {
"react": "https://cdn.example.com/react@18.3.1/index.js",
"react-dom": "https://cdn.example.com/react-dom@18.3.1/index.js",
"@company/design-system": "https://ds.example.com/v2.5.0/index.js"
}
}
</script>
<!-- Each MFE imports based on this map -->
<script type="module">
import React from 'react'; // Import Map determines the version
import { Button } from '@company/design-system';
</script>To change a version, you update a single line of HTML. Since you don't need to touch each team's build configuration, applying a singleton version policy is very explicit. That said, a CDN outage becoming a single point of failure is something you must account for before going to production.
Pros and Cons Analysis
Advantages
| Item | Description |
|---|---|
| Independent deployment | Teams can deploy on their own without depending on other teams' release cycles |
| Technology autonomy | Allows choosing the framework and library best suited for the domain |
| Clear responsibility boundaries | Contracts formalize interfaces, making ownership explicit |
| Incremental migration | Parts of the legacy system can be replaced while the rest continues operating |
| Governance automation | Rules embedded in the build pipeline mean overhead doesn't grow linearly as teams scale |
Disadvantages and Caveats
| Item | Description | Mitigation |
|---|---|---|
| Shared dependency dilemma | Sharing causes version lock-in; not sharing causes bundle bloat | Decide singleton policy upfront and enforce it via Import Maps or MF shared config |
| Contract drift | The Remote's actual interface diverges from its documentation and types | MF 3.0 automatic type generation + Pact contract testing as CI gates |
| Design fragmentation | When shared components "belong to everyone," nobody takes responsibility | Assign a design system owner team; visual regression testing with Storybook + Chromatic |
| Upfront investment cost | Establishing the governance framework requires significant initial investment | Consider adopting when you have 5+ teams and independent deployment needs actually conflict |
| Infrastructure complexity | Deployment pipelines, monitoring, and dev environments all become more complex | Visibility through Backstage portal; per-MFE tracing with OpenTelemetry |
What is Contract Drift? The phenomenon where actual implementation and formal contracts (API specs, type definitions) diverge over time. In MFE environments, this drift directly causes runtime errors for other teams, making it far more than a simple documentation management problem.
The Most Common Mistakes in Practice
-
Implementing governance as a review board — Having people review things manually becomes a bottleneck as teams grow, and eventually everyone finds ways to bypass the gatekeeper. Rules must exist as automation inside CI/CD.
-
Deferring shared dependency policy decisions — Many teams push Module Federation configuration first and decide
singleton: truelater. Changing it afterward means touching every Remote configuration, which is far more cumbersome than expected. -
Applying full MFE governance when team size is small — If you have three or fewer teams or independent deployment needs don't actually conflict, a monorepo + shared packages strategy is more practical. The Feature-Sliced Design community also considers around 5+ teams to be the appropriate threshold for adopting MFE.
Closing Thoughts
I still remember the Slack message that prompted this article. Ultimately, the root cause of that outage was that contracts, bundle budgets, and ownership all existed only inside people's heads. When the code and pipeline don't know what each team exposes and which version of React should be used, incidents repeat themselves.
Three steps you can start with right now:
-
Start with contract visibility. List which Remote modules are currently passed between teams, and document owning teams and exposed modules in
catalog-info.yamlformat. Starting with plain Markdown files without Backstage installed is entirely sufficient. -
Add a single bundle budget gate to CI. Add a step that measures the size of each Remote's
remoteEntry.json every PR using Bundlemon orwebpack-bundle-analyzerwith a custom script. Starting with current size + 20% headroom is more than adequate. -
Keep a one-page shared dependency policy document for the team. Recording "what to share as singletons and what the version upgrade cadence should be" in ADR (Architecture Decision Record) format means you won't have to repeat the same debates when more Remotes are added.
References
- Need micro-frontend benefits at scale? Reimagine the operating model | McKinsey & Company
- Micro-Frontends: Are They Still Worth It in 2025? | Feature-Sliced Design
- Micro-frontends 2026: Module Federation 3.0 & Native ESM Federation | weskill.org
- 대규모 프론트엔드 아키텍처의 새로운 패러다임 | 올리브영 테크블로그
- Micro Frontends | Martin Fowler
- Enabling Typed Micro-Frontend Architectures | arXiv (2025)
- Backstage by Spotify — The Ultimate Guide 2026 | Roadie.io
- Practical micro frontends: How we orchestrated multiple frameworks with single-spa | Nutrient
- Micro-Frontends: a Sociotechnical Journey | InfoQ
- Maximizing the value of CX modernization with micro frontends | McKinsey