Mapping Domain Boundaries to Micro-Frontend Ownership #
When boundaries are drawn by file structure instead of business capability, two teams end up editing the same remote and nobody owns the seam — this guide shows how to translate bounded contexts into a clean one-team-per-remote ownership model.
The goal is concrete: every route resolves to exactly one owning remote, every remote has exactly one owning team, and the contract at each seam is explicit. Get this mapping right and the tradeoffs covered in Defining Application Boundaries become tractable. Get it wrong and you inherit the coupling problems described in Managing Cross-Team Coupling.
Prerequisites #
- A working host/remote setup (Webpack 5 Module Federation,
@module-federation/vite, or import maps). Versions: Webpack^5.80,react ^18.2, Node>=18. - A repo (mono or poly) where you can add a
CODEOWNERSfile and a top-levelownership.yaml. - A rough list of your application’s user-facing capabilities (checkout, search, account, dashboard).
- A team roster: which squads exist and roughly what they own today.
This is an organizational mapping exercise with a code artifact at the end. The mapping is the deliverable; the manifest and CODEOWNERS make it enforceable.
Step 1 — Identify bounded contexts by capability, not by page #
Start from what the business does, not from your current folder layout. A bounded context is a slice of the domain with its own language and its own data — “checkout” means something specific inside the payments context that it does not mean inside catalog.
List capabilities and the domain events that flow between them. Events are the clearest boundary signal: where one capability publishes and another subscribes, you have a seam.
# Sketch contexts from your event log or analytics, one capability per line.
# The verbs (the events) tell you where one context ends and another begins.
cat > contexts.txt <<'EOF'
checkout -> emits: OrderPlaced, PaymentAuthorized
catalog -> emits: ProductViewed, SearchPerformed
account -> emits: ProfileUpdated, SignedIn
EOF
If two “capabilities” always change together and share the same vocabulary, they are one context, not two. Merge them now — splitting a remote later is far cheaper than merging two teams.
Step 2 — Map each context to one remote and one team #
Draw the table before you write any config. The mapping is one-to-one in both directions: a context owns a remote, a team owns the remote. No remote should appear under two teams.
| Bounded context | Remote (name) |
Owning team | Route prefix |
|---|---|---|---|
| Checkout | checkout |
Payments | /checkout |
| Catalog | search |
Discovery | /search, /products |
| Account | account |
Identity | /account |
| Shell/host | host |
Platform | / (composition only) |
The host owns no domain. It owns composition, routing, and the shared shell. If a domain feature is creeping into the host, that is a boundary smell — covered in troubleshooting below. For small organizations, confirm this split is even worth it by checking the thresholds in When to Avoid Micro-Frontends for Small Teams.
Step 3 — Define the contract at each seam #
A team owns a remote end to end, so other teams must never reach into its internals. The seam is a published contract: exposed components take props, and cross-context communication happens through events — never through shared mutable state or imported internal modules.
// checkout/webpack.config.js — the seam is what you expose, nothing more.
new ModuleFederationPlugin({
name: 'checkout',
filename: 'remoteEntry.js',
exposes: {
// Public contract: a component with a typed prop surface.
'./CheckoutWidget': './src/public/CheckoutWidget',
},
shared: {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
},
});
The host consumes only the public export and passes data in by props, reacting to domain events on the way out:
// host/src/routes/CheckoutRoute.tsx
import CheckoutWidget from 'checkout/CheckoutWidget';
export function CheckoutRoute({ cartId }: { cartId: string }) {
return (
<CheckoutWidget
cartId={cartId}
onOrderPlaced={(orderId) =>
window.dispatchEvent(
new CustomEvent('order:placed', { detail: { orderId } }),
)
}
/>
);
}
Anything the host needs from checkout arrives as a prop or an event. Nothing arrives as a shared singleton store reaching across the seam.
Step 4 — Assign route ownership #
Routing is where boundaries become visible to users. Give each remote a route prefix it owns exclusively; the host lazy-loads the matching remote and delegates everything under that prefix.
// host/src/router.tsx — one prefix, one remote, no overlap.
import { lazy } from 'react';
import { createBrowserRouter } from 'react-router-dom';
const Checkout = lazy(() => import('./routes/CheckoutRoute'));
const Search = lazy(() => import('./routes/SearchRoute'));
const Account = lazy(() => import('./routes/AccountRoute'));
export const router = createBrowserRouter([
{ path: '/checkout/*', element: <Checkout /> }, // Payments
{ path: '/search/*', element: <Search /> }, // Discovery
{ path: '/products/*', element: <Search /> }, // Discovery
{ path: '/account/*', element: <Account /> }, // Identity
]);
Nested routes inside /checkout/* belong to the checkout remote and its team — the host does not register them. This keeps deep-route ownership with the domain team rather than centralizing it in the shell.
Step 5 — Record ownership in a manifest and CODEOWNERS #
Make the mapping a checked-in artifact so it is enforced, not remembered. The manifest is the single source of truth; CODEOWNERS makes GitHub require the right reviewers automatically.
# ownership.yaml — single source of truth for boundaries.
remotes:
- name: checkout
context: Checkout
team: payments
routes: ["/checkout"]
exposes: ["./CheckoutWidget"]
repo: org/checkout-mfe
- name: search
context: Catalog
team: discovery
routes: ["/search", "/products"]
exposes: ["./SearchPanel", "./ResultsGrid"]
repo: org/search-mfe
- name: account
context: Account
team: identity
routes: ["/account"]
exposes: ["./AccountMenu"]
repo: org/account-mfe
host:
name: host
team: platform
owns: ["composition", "routing", "shell"]
# CODEOWNERS — derived from ownership.yaml. One path, one team.
/checkout-mfe/ @org/payments
/search-mfe/ @org/discovery
/account-mfe/ @org/identity
/host/src/router.tsx @org/platform
/ownership.yaml @org/platform @org/architecture
When a new remote is proposed, the pull request that adds it must add a row here too. No manifest entry, no merge.
Step 6 — Forbid shared-context coupling #
The fastest way to dissolve boundaries is a “shared” util or context that every remote imports. Allow shared libraries (design system, the React singleton) but forbid shared domain state. Domain data crosses seams as events, as discussed in the cross-team coupling guide.
# Fail CI if a remote imports another remote's internals or a domain "shared" module.
grep -rEn "from ['\"](checkout|search|account)/(?!.*remoteEntry)" ./src \
&& { echo "Cross-remote internal import detected"; exit 1; } || echo "Seams clean"
A shared package is acceptable only when it has no domain knowledge and its own owner in the manifest. The moment it knows what an “order” is, it belongs to a context.
Verification #
Confirm the mapping holds before calling it done.
Every route resolves to exactly one owning remote. Parse the manifest and assert no route prefix is claimed twice:
node -e '
const y = require("js-yaml").load(require("fs").readFileSync("ownership.yaml","utf8"));
const seen = {};
for (const r of y.remotes) for (const route of r.routes) {
if (seen[route]) throw new Error(`Route ${route} owned by ${seen[route]} and ${r.name}`);
seen[route] = r.name;
}
console.log("OK: every route maps to one remote");
'
No orphaned shared modules. Every entry under a shared/ path should resolve to an owner in CODEOWNERS. Run git ls-files shared/ and confirm each path has a matching CODEOWNERS rule; an unowned shared module is a future god-module.
One team per remote. grep -c the team field — each remote should list a single team, and the count of unique teams should equal the count of remotes plus the host.
Troubleshooting #
Two teams editing one remote. Symptom: the same remote shows commits from two squads and review ownership is ambiguous. Diagnosis: the remote spans two bounded contexts that were merged by accident. Fix: split the remote along the event boundary — extract the second context’s exposes into a new remote with its own manifest row and CODEOWNERS entry.
God-remote. Symptom: one remote exposes a dozen unrelated components and every feature touches it. Diagnosis: the context was defined by layer (“UI components”) rather than capability. Fix: re-run Step 1 from domain events, carve the god-remote into capability-aligned remotes, and migrate routes one prefix at a time.
Boundary drift. Symptom: a route that used to live in one remote now renders pieces from another. Diagnosis: a team added functionality outside its context because the seam was easier to cross than to extend. Fix: move the feature behind the correct remote’s contract, then add the route-uniqueness check from Verification to CI so drift fails the build.
Shared util creep. Symptom: a shared/utils package keeps growing and now imports domain types. Diagnosis: teams are using “shared” as a dumping ground to avoid defining contracts. Fix: assign the package a single owner in the manifest, strip any domain knowledge into the owning remote, and gate new additions on owner review.