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 #

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.

Bounded contexts to remotes to teams Each bounded context maps to exactly one remote, and each remote maps to exactly one owning team, with contracts at the seams. Bounded context Remote Owning team Checkout context checkout remote Payments squad Catalog context search remote Discovery squad Account context account remote Identity squad One context → one remote → one team. No fan-out.
Each bounded context maps to a single remote with a single owning team; seams are crossed only through explicit contracts.

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.