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.
Reviewing the map as the organisation changes #
An ownership map is a snapshot of an organisation, and organisations reorganise. Teams merge, capabilities move, a product area is split in two — and the map silently stops describing reality, usually several months before anyone notices.
The cheapest way to keep it honest is to derive part of it rather than maintaining all of it. Route ownership can be read from the manifest, code ownership from CODEOWNERS, and escalation targets from the on-call system’s API. Anything that can be generated will not drift; anything typed into a document will.
For the parts that genuinely need a human decision — which capability a new feature belongs to, whether a boundary should move — a quarterly review with the affected teams is enough. The useful input to that review is not the map itself but the evidence: how many changes in the last quarter touched more than one boundary, and which pairs of remotes appeared together most often. A pair that keeps appearing is a boundary the organisation is working around, and the review is where you decide whether to move it or accept it.
The failure to watch for is a boundary with no owner at all. It happens when a team is dissolved and its remote is inherited by nobody in particular, and it is invisible until an incident, when the escalation path leads to an empty channel. An automated check that every remote in the manifest resolves to a team that currently exists costs almost nothing and catches this the week it happens rather than the quarter after.