Shared Ownership of the Host Shell Application #

Every federated architecture eventually discovers that the shell is a single-team dependency every other team needs changed — this page narrows the shell’s mandate and opens a contribution path, so it stops being the queue that undoes independent deployment.

The symptom is easy to recognise: teams describe themselves as autonomous while their release notes are full of “waiting on the platform team”. The cause is almost never the platform team’s throughput. It is that the shell owns things it should not, and provides no way to extend the things it should. It applies the ownership thinking in managing cross-team coupling.

Two ways to own a host shell The bottleneck is not the platform team's capacity — it is that registration and extension are code changes rather than data. Shell as a shared codebase Every route registration is a pull request Remote names appear in shell code One team reviews everything "Autonomous" teams wait weekly Shell as a narrow platform Registration is a manifest entry Remotes fill named slots Ownership split within the file tree Shell changes are rare by design
The measure of success is how rarely remote teams need to touch the shell at all.

Prerequisites #

Step 1 — Write down what the shell owns #

Ambiguity is what makes the shell accumulate responsibilities. Publish an explicit list and treat anything outside it as a candidate for removal.

The shell owns exactly five things: the document — head, meta, the token stylesheet, the theme attribute; the routing table that maps a path prefix to an owning remote; authentication and session resolution; the layout frame — header slot, main slot, footer slot; and the shared-dependency policy.

That list should feel uncomfortably short. Everything else — page content, navigation items, feature flags for a remote’s own features, per-remote error copy — belongs to a remote or to configuration.

// shell/OWNERSHIP.md, enforced by review rather than only documented
export const SHELL_OWNS = [
  'document head and token application',
  'route prefix → remote mapping',
  'authentication and session',
  'layout slots',
  'shared dependency policy',
];

Step 2 — Make registration data, not code #

Registration as data rather than code Driving routes, navigation and slot contributions from the manifest removes the most common reason a remote team needs the shell changed. How a remote registers itself Team edits manifest own pipeline Shell reads it at runtime Routes derived overlaps fail the build Nav and slots filled no shell code change
The overlap check in stage three is what keeps manifest-driven registration safe rather than merely convenient.

The most common reason a team waits on the shell is registering a route or a navigation entry. Both should be manifest entries, not pull requests against the shell.

{
  "cart": {
    "entry": "https://cdn.example.com/cart/4.3.0/remoteEntry.js",
    "module": "./Routes",
    "routes": ["/cart/*", "/checkout/*"],
    "nav": [{ "label": "Basket", "href": "/cart", "order": 30, "icon": "basket" }],
    "owner": "payments",
    "slotHeight": 320
  }
}
// shell/src/routes.js — the routing table is derived, never hand-written
export function buildRoutes(manifest) {
  return Object.entries(manifest).flatMap(([name, spec]) =>
    (spec.routes ?? []).map((path) => ({
      path,
      element: <RemoteRoute name={name} module={spec.module} />,
    })),
  );
}

Once this is in place, a team adding a route edits its own manifest entry in its own pipeline. The shell’s code does not change, no review is needed, and the change ships on that team’s schedule.

Validate the derived table rather than trusting it: overlapping route prefixes should fail the build, since two remotes claiming one path is a configuration bug that is otherwise resolved arbitrarily at runtime.

Step 3 — Give remotes named extension points #

Some things genuinely have to render inside the shell — a notification badge in the header, a step indicator in a checkout flow. Slots let a remote fill them without the shell knowing anything about that remote.

// shell/src/Slot.jsx — a named region any remote may contribute to
export function Slot({ name }) {
  const contributions = useSlotContributions(name);   // read from the manifest
  return (
    <div data-slot={name}>
      {contributions.map(({ remote, module, key }) => (
        <RemoteErrorBoundary key={key} remote={remote} fallback={null}>
          <Suspense fallback={null}>
            <RemoteComponent remote={remote} module={module} />
          </Suspense>
        </RemoteErrorBoundary>
      ))}
    </div>
  );
}
{
  "notifications": {
    "slots": [{ "name": "header-actions", "module": "./Bell", "order": 20 }]
  }
}

Two constraints keep slots from becoming an escape hatch that recreates the coupling. Slot names are a versioned contract owned by the shell — renaming one is a breaking change. And every contribution is individually error-bounded with a null fallback, so a remote that fails to render its badge cannot take the header with it.

Step 4 — Open contribution with clear review boundaries #

When the shell must change, the bottleneck is review capacity rather than write access. Split ownership within the file tree so most changes need only a lightweight review.

# shell/CODEOWNERS
*                           @org/platform
/src/routes/                @org/platform
/src/auth/                  @org/platform @org/security
/src/layout/                @org/platform
/config/remotes.json        @org/platform @org/payments @org/discovery @org/identity
/src/slots/registry.ts      @org/platform

The manifest line is the important one. Every team owns it jointly, so a team registering its own remote does not queue behind the platform team at all.

Pair this with a written contribution guide that states which kinds of change are expected from remote teams — a manifest entry, a new slot contribution — and which need a design discussion. Most contention comes from teams not knowing which category they are in.

Step 5 — Move the shell’s incidental responsibilities out #

Where each responsibility actually belongs Half of what accumulates in a shell belongs to a remote, and moving it out is the change that removes the queue. Responsibility Belongs to Why Route prefix mapping shell (from manifest) someone must arbitrate overlaps Authentication shell one session, resolved before routing Design tokens design system one source, consumed by all A remote's feature flags that remote the shell has no stake A remote's loading copy that remote it knows its own content Composed-page incidents shell team, by default someone must own the whole page
Search the shell for remote names as string literals — each one marks a responsibility sitting in the wrong place.

The shell tends to accumulate things that were convenient to put there once. Each one is a reason another team waits.

Feature flags for a remote’s own features belong to that remote, read from its own flag client. Per-remote loading and error copy belongs in the remote, which knows what its content is. Analytics for a remote’s interactions belongs in the remote, tagged with its own name. Remote-specific styling in the shell is almost always compensating for something the remote should fix.

// Before: the shell knows about a remote's internals.
if (remote === 'cart' && flags.newCheckout) renderNewCheckoutHeader();

// After: the shell renders a slot; the remote decides what goes in it.
<Slot name="checkout-header" />

Work through the shell looking for the remote’s name as a string literal. Each occurrence is a place where the shell knows something it should not, and each is a small, safe change to remove.

Step 6 — Measure whether teams are actually unblocked #

The claim “teams are autonomous” is testable, and the measurement changes the conversation from opinion to data.

// scripts/shell-dependency-report.mjs — how often do remote teams need the shell?
const prs = await listMergedPullRequests('shell', { since: '30d' });

const byTeam = new Map();
for (const pr of prs) {
  const team = pr.author.team;
  if (team === 'platform') continue;
  byTeam.set(team, (byTeam.get(team) ?? 0) + 1);
}

for (const [team, count] of byTeam) {
  console.log(`${team}: ${count} shell changes in 30 days (median wait ${medianWait(team)}h)`);
}

A team needing shell changes more than once or twice a month is not autonomous, whatever the architecture diagram says. Read each of those changes and ask what extension point would have made it unnecessary — the answer is usually a slot or a manifest field, and it is a small piece of work with a large effect.

Step 7 — Decide who is on call for the shell #

Ownership includes the pager, and this is where shared ownership gets uncomfortable enough that teams avoid deciding it.

The workable split follows the same lines as the code. The platform team is on call for the shell itself — routing, auth, layout, the federation runtime. Each remote team is on call for its own remote, including when the symptom is that its remote failed to load. Per-remote telemetry, as described in error boundary telemetry for remote apps, is what makes that routing automatic rather than a judgement call at 3am.

The case needing an explicit rule is a failure that is only visible in composition — two remotes that individually work and together do not. Assign these to the platform team by default, with the expectation that they pull in whichever remote team the investigation implicates. Someone has to own the composed page, and leaving it unassigned means it is investigated by whoever notices.

Verification #

Troubleshooting #

Symptom: teams still open shell pull requests weekly.

Diagnosis: something they need routinely is code rather than data. Fix: read the last ten such changes; the repeated pattern names the extension point to build.

Symptom: a slot contribution broke the header.

Diagnosis: the contribution is not individually error-bounded. Fix: wrap each one, with null as the fallback for decorative regions.

Symptom: platform review is the bottleneck on manifest changes.

Diagnosis: CODEOWNERS gives the platform team sole ownership of the manifest. Fix: make it jointly owned so a team can register its own remote.

Symptom: the shell keeps growing despite the ownership list.

Diagnosis: the list is documented but not enforced in review. Fix: add a checklist item to the shell’s pull request template asking which of the five responsibilities the change belongs to.