Defining Application Boundaries #
The boundary you draw between two micro-frontends is the single most expensive line in the system to move later. Get it right and each team ships on its own cadence with a small, stable seam between them. Get it wrong and you inherit the worst of both worlds: the deployment overhead of distributed apps with the coupling of a monolith.
This guide sits under Core Micro-Frontend Architecture Tradeoffs and focuses on one decision: where the seams go and how to encode them in your build. Once you have read it, go deeper on the two questions it raises most often — when to avoid micro-frontends for small teams (the cost side of the decision) and mapping domain boundaries to micro-frontend ownership (turning a domain model into an ownership map).
What actually breaks when boundaries are wrong #
Bad boundaries do not announce themselves on day one. They surface months in, when the org has already committed to the split. Three failure modes dominate.
The chatty seam. Two remotes that belong to the same domain end up passing dozens of props, events, and shared state objects back and forth. Every feature touches both. The teams meet constantly to coordinate. The boundary exists in the build but not in reality, and it adds latency and deploy friction for no isolation benefit.
The leaky boundary. A developer imports ../../checkout/src/internal/PriceCalc directly instead of going through the exposed surface. It compiles, it ships, and now the checkout team cannot refactor PriceCalc without breaking a remote they have never heard of. The boundary is decorative.
The fragile host. A remote changes the shape of an exposed component’s props, or bumps a shared library past the host’s requiredVersion. The host shell white-screens in production because one remote failed to load. Without a contract and a fallback, a single team’s deploy takes down the whole page.
Each of these traces back to the same root cause: a boundary that was drawn by org chart or by file-folder convenience rather than by the natural cohesion of the domain. The rest of this guide is about drawing it deliberately and then defending it in code.
Objectives #
By the end you should be able to:
- Translate bounded contexts into a small set of independently deployable remotes with a clear public surface each.
- Configure
ModuleFederationPluginso that only the exposed surface is reachable and shared libraries resolve to a single instance. - Wire host and remotes so a failed or mismatched remote degrades gracefully instead of crashing the shell.
- Enforce the boundary in CI so leaks fail the build rather than reaching production.
How to find the seam before you write any config #
A good boundary follows a bounded context — a part of the domain with its own language, its own data, and ideally its own team. Checkout, search, account, and catalog are usually different contexts. “Header” and “footer” usually are not; they are shared chrome, not domains.
Two heuristics keep you honest:
- Cohesion test. If a typical feature lives almost entirely inside one boundary, the boundary is good. If most features straddle two, you have drawn the line through the middle of a single context — merge them.
- Coupling test. Count the props, events, and shared state objects crossing the seam. A handful is healthy. Dozens means the two sides are really one thing.
When in doubt, draw fewer boundaries. Merging two remotes later is a refactor; splitting a chatty pair is the same refactor plus undoing every cross-call you added in the meantime.
Vertical vs horizontal splits #
There are two fundamentally different ways to cut a frontend into remotes, and choosing the wrong axis is how a clean diagram becomes a chatty seam.
A vertical split slices by domain: checkout, search, and account each own their whole stack — UI, state, data fetching, and the routes that render them. A team owns a vertical end to end and deploys it without touching anyone else’s code. This is the split that pays for itself, because a feature usually lives inside one vertical and rarely crosses the line.
A horizontal split slices by layer: one team owns “all the forms”, another owns “the data layer”, another owns “the design system components”. It looks tidy on an architecture slide, but almost every feature now needs a change in every horizontal slice, and the seams fill with traffic. Horizontal splits recreate the layered-monolith coupling problem on top of distributed-deploy overhead — the exact worst-of-both-worlds the intro warned about.
The rule of thumb: split vertically by default, and treat shared chrome (design system, the shell’s navigation) as a published package rather than a remote. A package is consumed at build time and versioned; it is not a deploy boundary, and pretending it is one is how teams end up with a “god remote” that everything imports from.
Route-based vs component-based boundaries #
Once you have chosen vertical slices, decide how each remote attaches to the host. There are two attachment styles, and a mature app usually uses both.
A route-based boundary gives the remote a whole URL subtree. The host’s router sees /checkout/* and hands the entire branch to the checkout remote, which then owns its own nested routing. This is the cleanest possible seam — the contract is essentially a single mount point plus the URL path — and it maps one-to-one onto how users think about the app. Most of your remotes should be route-based.
A component-based boundary embeds a remote’s component inside a host-owned page — a recommendations widget on the catalog page, a saved-cards panel inside an account screen. The seam here is a props/events contract on a single component, which is tighter and more demanding than a route handoff because the host renders around it.
The trade-off is autonomy versus composition. Route-based remotes are maximally independent but cannot be co-located on one screen; component-based remotes compose richly but couple the host’s layout to the remote’s render. Reach for component-based boundaries only when two domains genuinely need to share a screen — otherwise the looser route handoff ages better.
A worked example: routes and domains to remotes #
Make the mapping concrete before writing config. Start from the URL map and the domain, and produce an explicit ownership table — this is the artifact a reviewer and an on-call engineer both need.
| Route subtree | Bounded context | Remote | Owning team | Boundary style |
|---|---|---|---|---|
/, /search, /p/:id |
Catalog | catalog |
Discovery | Route-based |
/cart, /checkout/* |
Checkout | checkout |
Payments | Route-based |
/account/*, /orders/* |
Account | account |
Identity | Route-based |
recommendations widget on /p/:id |
Catalog → ML | recommend |
Data Science | Component-based |
That table drives the host router directly. Each route subtree resolves to one remote; the one component-based seam is the only place the host renders inside a remote’s space.
// host/src/AppRoutes.tsx — routes map 1:1 onto remotes
import { Routes, Route } from 'react-router-dom';
import { RemoteSlot } from './RemoteSlot';
export function AppRoutes() {
return (
<Routes>
{/* Route-based: each remote owns its whole subtree via a splat. */}
<Route path="/*" element={<RemoteSlot scope="catalog" module="./Root" fallback={Skeleton} />} />
<Route path="/checkout/*" element={<RemoteSlot scope="checkout" module="./Root" fallback={Skeleton} />} />
<Route path="/account/*" element={<RemoteSlot scope="account" module="./Root" fallback={Skeleton} />} />
</Routes>
);
}
The deployable manifest mirrors the table one-for-one, so the mapping you reviewed is the mapping that ships:
// remotes.json — runtime registry, one entry per remote in the table
{
"catalog": { "url": "https://cdn.example.com/catalog/v3.4.1/remoteEntry.js" },
"checkout": { "url": "https://cdn.example.com/checkout/v2.1.0/remoteEntry.js",
"fallbackUrl": "https://cdn-backup.example.com/checkout/v2.1.0/remoteEntry.js" },
"account": { "url": "https://cdn.example.com/account/v1.8.2/remoteEntry.js" },
"recommend": { "url": "https://cdn.example.com/recommend/v0.9.0/remoteEntry.js" }
}
The route table, the host router, and the manifest are three views of a single decision. When they drift — a route with no remote, a remote with no owner — that drift is the early warning that a boundary is eroding. Turning a domain model into this table in the first place is the subject of mapping domain boundaries to micro-frontend ownership.
Ownership: one team per boundary #
A boundary without a single owning team is a boundary that erodes. The CODEOWNERS file is where the ownership column of the table becomes enforceable — every path in a remote routes review to exactly one team, and the exposed surface gets an extra reviewer so contract changes are never rubber-stamped.
# CODEOWNERS — ownership mirrors the route-to-remote table
/checkout/ @org/payments
/checkout/src/public/ @org/payments @org/platform # contract = two reviewers
/catalog/ @org/discovery
/account/ @org/identity
remotes.json @org/platform # the manifest is shared infra
Two ownership rules keep boundaries from blurring. First, no shared write access to another remote’s internals — if Payments needs a change in Catalog, it is a PR to Discovery, not a quiet cross-edit. Second, the manifest and the shell are platform-owned, because they are the one place every team’s deploys converge.
Setup and config #
Once the seam is chosen, encode it in ModuleFederationPlugin. The configuration does two jobs at once: it declares the public surface (exposes) and it pins shared libraries so the boundary does not accidentally ship a second copy of React.
// checkout/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
const deps = require('./package.json').dependencies;
module.exports = {
output: {
// Each remote owns a stable, unique name in the global scope.
uniqueName: 'checkout',
publicPath: 'auto',
},
plugins: [
new ModuleFederationPlugin({
name: 'checkout',
filename: 'remoteEntry.js',
// The PUBLIC surface. If a path is not here, the host cannot import it.
exposes: {
'./PaymentForm': './src/public/PaymentForm.tsx',
'./useCartTotal': './src/public/useCartTotal.ts',
},
shared: {
react: { singleton: true, strictVersion: true, requiredVersion: deps.react },
'react-dom': { singleton: true, strictVersion: true, requiredVersion: deps['react-dom'] },
// Design tokens are stateless: dedupe but tolerate minor drift.
'@org/design-tokens': { singleton: false, requiredVersion: deps['@org/design-tokens'] },
},
}),
],
};
A few non-obvious choices matter for boundary integrity:
- A dedicated
src/public/directory. Everything reachable from outside lives here, andexposesonly ever points into it. This makes the public surface a thing you can see in the file tree, lint against, and review in a PR. singleton: trueplusstrictVersion: trueon React. Two React instances mean broken hooks across the seam.strictVersionturns a silent dual-load into a loud, fixable error at integration time. The trade-offs of singleton sharing are covered in depth under managing cross-team coupling.requiredVersionfrompackage.json, not a hand-typed string. Hard-coding'^18.2.0'drifts the moment someone bumps the dependency. Reading it fromdepskeeps the declared boundary honest with the actual install.
The host side declares which remotes it knows about. Keep the URLs out of the bundle so a remote can move without rebuilding the host:
// host/webpack.config.js
new ModuleFederationPlugin({
name: 'host',
remotes: {
// Promise-based remote: URL is resolved at runtime, not baked in.
checkout: `promise import('checkout/loadEntry').then(m => m.load())`,
catalog: `promise import('catalog/loadEntry').then(m => m.load())`,
},
shared: {
react: { singleton: true, strictVersion: true, requiredVersion: deps.react },
'react-dom': { singleton: true, strictVersion: true, requiredVersion: deps['react-dom'] },
},
});
A TypeScript declaration keeps the seam type-safe on the consuming side without leaking the remote’s internals:
// host/src/remotes.d.ts
declare module 'checkout/PaymentForm' {
import type { FC } from 'react';
export interface PaymentFormProps {
orderId: string;
onComplete: (receiptId: string) => void;
}
const PaymentForm: FC<PaymentFormProps>;
export default PaymentForm;
}
This file is the contract from the host’s point of view. If the checkout team changes PaymentFormProps, they must ship an updated declaration, and that diff is exactly where a reviewer notices a breaking change.
Integration: wiring host and remotes #
With config in place, the host needs to mount remote modules safely. The runtime loader resolves the remote’s real URL, initializes the shared scope, and hands back the module factory.
// host/src/loadRemote.ts
type Manifest = Record<string, { url: string; fallbackUrl?: string }>;
let manifestPromise: Promise<Manifest> | null = null;
const getManifest = () =>
(manifestPromise ??= fetch('/remotes.json').then((r) => r.json()));
export async function loadRemote<T>(scope: string, exposed: string): Promise<T> {
const manifest = await getManifest();
const entry = manifest[scope];
if (!entry) throw new Error(`Remote "${scope}" is not registered.`);
// Share the host's already-initialized scope so React is not loaded twice.
await __webpack_init_sharing__('default');
const container = await loadScript(scope, entry.url, entry.fallbackUrl);
await container.init(__webpack_share_scopes__.default);
const factory = await container.get(exposed);
return factory() as T;
}
On the React side, wrap every remote mount point in a Suspense boundary for the load and an error boundary for the failure. The two together are what turn “a remote is down” into “a small placeholder” instead of “the page is blank”.
// host/src/RemoteSlot.tsx
import { Suspense, lazy, type ComponentType } from 'react';
import { RemoteErrorBoundary } from './RemoteErrorBoundary';
import { loadRemote } from './loadRemote';
export function RemoteSlot<P extends object>(props: {
scope: string;
module: string;
fallback: ComponentType;
} & P) {
const { scope, module, fallback: Fallback, ...rest } = props;
const Lazy = lazy(() => loadRemote<{ default: ComponentType<P> }>(scope, module));
return (
<RemoteErrorBoundary fallback={<Fallback />}>
<Suspense fallback={<Fallback />}>
<Lazy {...(rest as P)} />
</Suspense>
</RemoteErrorBoundary>
);
}
For anything beyond rendering a component — shared user identity, a cart total, a theme — resist the urge to import it across the seam. Communicate through a narrow, explicit channel instead. The patterns for this live under cross-app state and context sharing; the boundary rule is simply that the channel is part of the contract and everything else stays private.
Edge cases #
Boundaries that look clean in a demo fail in specific, repeatable ways under real traffic.
Version mismatch on a shared singleton. The host requires React ^18.2.0; a stale remote was built against ^17. With strictVersion: true the remote refuses to init, which is correct — better a caught error than a corrupted hook tree. Catch it in the error boundary and surface a “please refresh” placeholder. The longer-term fix is version negotiation, covered in versioning strategies for remote apps.
Partial load / race condition. Two remotes init the shared scope concurrently and both try to register React. Module Federation handles this if — and only if — every consumer awaits __webpack_init_sharing__('default') before calling container.init. Initializing in parallel without awaiting the shared scope is the classic source of “hooks can only be called inside a function component” across a seam.
Serialization across the seam. Passing a class instance, a Date, or a Redux store through an event or postMessage and expecting methods to survive. Only plain, serializable data should cross a boundary; treat the seam like a network call even when it is in-process.
Circular remotes. Remote A imports from B and B from A. Federation will resolve it at runtime but you have recreated a monolith with extra steps. Extract the shared piece into a versioned package, or hoist it into the host as a provided value, so the dependency arrow points one way.
CSS bleed. One remote’s global selector restyles another’s buttons. Scope styles per remote with CSS Modules or Shadow DOM; the boundary is not just JavaScript.
Shared-utilities creep. It starts innocently: two remotes both need a formatCurrency helper, so one imports it from the other. Then three remotes import a useDebounce, a date formatter, a validation schema. The “utils” remote slowly becomes a hidden coupling hub that every team must coordinate to change. The fix is to distinguish two kinds of shared code. Stateless, low-churn helpers belong in a versioned npm package consumed at build time — each remote pins its own version and upgrades on its own schedule. Only genuinely runtime-shared singletons (the design system, the auth client) belong in shared. If a piece of code is changing often and everyone depends on it, that is a missing bounded context, not a utility.
The “god remote”. One remote accumulates the shell layout, the navigation, the auth context, the design system, and three unrelated features because it was the first one built. Every other remote now depends on it, so it cannot be deployed independently and its team becomes a bottleneck for the whole org. Break it up by pulling each responsibility to where it belongs: the shell and navigation move into the host, the design system becomes a package, and each feature becomes its own vertical remote. The tell is a remote in everyone’s remotes list and on every team’s critical path — a deploy boundary that no longer isolates anything.
Cross-boundary state. A user logs in inside the account remote and the checkout remote needs to know. The wrong fix is for checkout to import account’s auth store directly, which welds the two boundaries together. The right fix keeps the seam narrow: lift the shared concern to a host-provided value or a published store package, and let both remotes depend on the contract, not on each other. The implementation patterns — context providers, event buses, an external store — live under cross-app state and context sharing; the boundary rule is that cross-boundary state is always mediated, never reached for directly across the seam.
Testing and validation #
A boundary that is not tested is a boundary that will leak. Three checks, in order of value:
- Contract tests on the exposed surface. Type-test the exposed props and run a consumer-driven contract (Pact or a thin custom matcher) so a breaking change to
PaymentFormPropsfails the remote’s own CI, not the host’s production. - A leak linter. Forbid deep imports across remotes so the public surface stays the only door in.
// .eslintrc.json — fail the build on cross-boundary deep imports
{
"rules": {
"no-restricted-imports": ["error", {
"patterns": [
{ "group": ["checkout/src/*", "!checkout/src/public/*"],
"message": "Import from the exposed surface, not checkout internals." }
]
}]
}
}
- Failure-mode integration tests. Mount the host against a remote that 500s, returns malformed
remoteEntry.js, or declares an incompatible version, and assert the error boundary renders the placeholder. This is the test that actually protects the host shell.
Deployment #
The payoff of a real boundary is independent deployment, so the pipeline must honor it.
- One pipeline per remote. Each remote builds, tests, and publishes its own content-hashed
remoteEntry.jsto its own bucket or path. No remote’s deploy should require rebuilding the host. - Manifest-driven URLs. Host shells read
remotes.jsonat runtime to resolve each remote’s current URL. Promoting a remote is a manifest update, not a host rebuild. - Cache headers that match volatility. Hashed chunks get
Cache-Control: immutable;remoteEntry.jsandremotes.jsongetno-cacheor a shortmax-age, so a new version propagates immediately instead of being pinned by a CDN edge. - Per-remote rollback. Because the manifest points at versioned artifacts, rolling back one remote is a one-line manifest change. Pair it with a feature flag for instant revert without a deploy at all.
Common pitfalls #
| Issue | Root cause and resolution |
|---|---|
| Boundary exists in config but every feature touches both remotes | The seam was drawn through one bounded context. Apply the cohesion test and merge the pair; a thin boundary between two halves of one domain is pure overhead. |
| A remote refactor breaks a consumer that imported its internals | Deep imports bypassed the exposed surface. Move the public API into src/public/, point exposes only there, and enforce no-restricted-imports in CI. |
| Host white-screens when one remote fails | No isolation around the mount point. Wrap every remote in an error boundary plus Suspense so a failed load degrades to a placeholder. |
| “Hooks can only be called inside a function component” across the seam | Two React copies, or shared scope initialized without awaiting. Set singleton: true + strictVersion: true, and always await __webpack_init_sharing__ before container.init. |
| New remote version never reaches users | remoteEntry.js cached at the edge. Serve it no-cache; reserve immutable for content-hashed chunks only. |
| Two remotes depend on each other and refuse to build cleanly | Circular boundary. Extract the shared module into a versioned package or provide it from the host so the dependency is one-directional. |
One remote is in every other remote’s remotes list |
A “god remote” absorbed shell, navigation, and shared UI. Move shell/nav into the host, ship the design system as a package, and split each feature into its own vertical remote. |
| A “utils” remote needs a coordinated change across teams to touch | Shared-utilities creep. Promote stateless helpers to a versioned npm package consumed at build time; reserve runtime shared for true singletons only. |
| Features keep needing edits in three remotes at once | Boundaries were cut horizontally by layer. Re-cut vertically by domain so a feature lands inside one remote. |
FAQ #
How many boundaries should we start with?
Fewer than you think — usually one remote per bounded context, which for most products is two to four. Splitting a cohesive domain in half creates a chatty seam; merging later is cheap, un-splitting a chatty pair is expensive. Add a boundary only when an independent deploy cadence or a genuine team split demands it.
Should the boundary follow the org chart or the domain?
The domain, then staff teams to match. Drawing the seam by reporting lines bakes today’s org structure into your build, and orgs reshuffle far more often than domains do. A boundary aligned to a bounded context survives a reorg; one aligned to a manager does not.
Can two boundaries share a single Webpack compilation in a monorepo?
Yes, and a monorepo makes contract changes easy to review in one PR. Keep each remote emitting its own remoteEntry.js and its own shared graph so they still deploy independently — a shared build tree is fine, a shared deploy unit defeats the purpose.
What is the minimum to keep one remote from crashing the whole page?
An error boundary around every remote mount point, singleton: true with strictVersion: true on shared frameworks, and remoteEntry.js served no-cache. Those three together convert most remote failures into a contained placeholder instead of a blank shell.
Should I split by route or by component?
Default to route-based boundaries — each remote owns a URL subtree, the seam is a single mount point, and the split mirrors how users navigate. Reach for a component-based boundary only when two domains genuinely need to live on the same screen, and accept that it couples the host’s layout to the remote’s render. A mature app uses route-based for almost everything and component-based for the rare shared screen.
Where should shared code live — a remote or a package?
It depends on whether the code is runtime-shared or build-time-shared. A true runtime singleton such as the auth client or design system goes in shared so there is one live instance. Stateless helpers and types belong in a versioned npm package each remote pins independently. Putting helpers in a “utils remote” creates a coordination hub that erodes every boundary that touches it.