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.
Prerequisites #
- An existing host shell owned by one team, with several remotes owned by others.
- A manifest-driven remote resolution, so registration does not require a host code change.
- The ability to define
CODEOWNERSon the shell repository. - Willingness to move work out of the shell, which is most of the change.
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 #
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 #
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 #
- Route registration needs no shell change: add a route in a manifest and confirm it works with the shell untouched.
- Slots are contained: make a slot contribution throw and confirm the surrounding region still renders.
- Overlaps fail: register two remotes claiming the same prefix and confirm the build fails.
- CODEOWNERS routes correctly: open a manifest-only change and confirm it does not require platform review.
- The shell does not name remotes: grep the shell for remote names as string literals. Ideally zero outside configuration.
- The dependency report is low: each remote team should need one or fewer shell changes a month.
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.