Page loaded: Miguel Carino | Angular Front-End Architect

Photo by Sean Lee on Unsplash

Angular Architecture

Two Copies of One Singleton

The shell logged the user in. The remote still thought they were a stranger — because 'shared' state wasn't actually shared.

Miguel Carino
Focus
Front-end architecture, Angular, team leadership, and stakeholder communication
Experience
25+ years turning enterprise web complexity into maintainable products

The shell authenticated the user. The header updated, the avatar appeared, every route the shell owned knew who was logged in. Then you clicked into the reports remote and it asked you to sign in again. Not a token-expiry edge case — a clean, confident "you are not logged in," from an app that was running inside the same browser tab, three pixels below a header that disagreed.

The shared auth service was providedIn: 'root'. In a single app, that means one instance, full stop. I had trusted that word — singleton — the way you trust a load-bearing wall. The problem was that there were two roots.

"providedIn: root" is a promise scoped to one injector

Here's the part that's easy to miss when you split an app across separately built, separately deployed remotes. providedIn: 'root' makes a service a singleton within an injector tree. The shell has a root injector. The remote, built in its own repository on its own schedule, has its own root injector — and unless the build is explicitly told otherwise, it ships its own copy of that auth service inside its own bundle. Two roots, two instances, each perfectly singleton inside its own world, each holding a different idea of whether you're logged in.

The mental model that betrayed me was thinking of the remote as a part of the shell, like a lazy-loaded route. It isn't. It's a guest that arrived with its own luggage, and the luggage included a second copy of the thing I assumed there could only be one of. The symptom looked like an auth bug. The cause was an architecture boundary I'd drawn without saying what crossed it.

It always shows up in the stateful dependencies first. A pure utility duplicated across remotes is wasteful but harmless — two copies of a date formatter format dates the same way. A duplicated store is a different animal: the moment two instances hold the same kind of state and only one of them gets the update, the UI starts contradicting itself.

A shared singleton shares the code — the instance is a second step

Module Federation — and its esbuild-era successor, Native Federation — has the first half of the answer. You declare which dependencies must be shared, and at load time the runtime makes sure exactly one copy of that library is loaded and that the versions are compatible across the shell and every remote:

// federation config — shell and every remote agree on the same contract
shared: {
  '@org/auth': { singleton: true, strictVersion: true },
  '@angular/core': { singleton: true, strictVersion: true },
}

That stops the remote from bundling a second copy of @org/auth, and strictVersion makes a mismatch fail loudly instead of silently. Necessary — but on its own it shares the code, not the state. Here's the part that took me a second pass to get right: @Injectable({ providedIn: 'root' }) creates one instance per root injector, and a separately bootstrapped remote still has its own root. Sharing the class makes both apps agree on the type of the service — but because each app is bootstrapped with its own root injector, each root still constructs its own object of that type. One copy of the code, two instances of the state — a smaller version of the same bug.

The instance is shared only when the value resolves through a single root, or lives above the roots entirely. The fix that held was the second one: let the shared package own the state at module scope, so the one shared copy is the single source of truth, no matter how many injectors reach for it.

// @org/auth — state created once, at module scope, inside the shared singleton
const user = signal<User | null>(null);
export const authStore = { user: user.asReadonly(), signIn /* … */ };
// inside the reports remote — unchanged consumer code, now correct
import { authStore } from '@org/auth';
authStore.user(); // the SAME signal the shell wrote to — logged in, as expected

Because @org/auth is a shared singleton there is one copy of that module, and because the state lives in the module rather than in a per-injector service, there is one user signal behind it. The header and the remote finally agree because they're reading the same object — and now that's true by construction, not by luck.

The alternative, when you'd rather keep the service inside Angular's DI, is to stop handing the remote its own root: load it into the shell's injector — a lazy route or component inside the host — so providedIn: 'root' means what it says again, because now there genuinely is one root. Either way the move is the same: collapse the two roots into one, or lift the state above both. Declaring the package shared is what makes that possible; it isn't, by itself, what makes it happen.

There's a third posture worth naming, and it's the one the federated demo actually takes: when the remotes are too independent to share an instance at all — separate teams, separate deploys, no appetite for a shared stateful package — you stop chasing one instance and instead let each keep its own, kept in sync across the boundary (a BroadcastChannel, a storage event, a server round-trip). That trades the certainty of a true singleton for autonomy, and it's the honest choice when forcing 'make it one' would recreate the coupling the split was meant to remove.

The strictVersion flag is the part I'd skip if I were rushing, and the part that saved me later. Two remotes upgrading the shared library on different sprints is the normal state of a polyrepo, and a silently mismatched singleton is a worse bug than a loud version error at load. I would rather the federation layer refuse to run than hand me one instance pretending to satisfy two incompatible contracts. A crash tells you where you are. A mismatch shared without anyone noticing tells you nothing until the state is already wrong.

You can watch the shell pull two independently deployed remotes into one running app — Angular and the design system shared as live singletons, and their cross-remote state kept in sync across the boundary — on the federated shell demo, with each remote served from its own origin.

The other half of this, worth its own post, is that the remote URLs can't be baked in at build time either — the shell has to resolve where its remotes live at runtime, or every environment promotion becomes a rebuild. Same lesson, different axis: a micro-frontend boundary is a set of agreements you have to state out loud, because the defaults assume one app, and you no longer have one app.

A Boundary Is Only Real If You Declare What Crosses It

Splitting a frontend into independently deployable remotes buys you real things — teams shipping on their own cadence, blast radius contained per remote. What it costs you is the comfortable assumption that "shared" and "singleton" are free. Across a federation boundary they are decisions, written into config, version-checked at load. Skip the declaration and the platform does the only safe thing it can: it gives each app its own copy, and lets them disagree in front of the user. Name what must be one thing, or the boundary will make it two for you.