The app was halfway across the bridge from AngularJS to Angular, running both frameworks in one page the way a hybrid migration lets you. A freshly rebuilt Angular component added an item to the cart, showed the new count, looked perfect. Then the still-AngularJS mini-cart in the header — the one nobody had touched yet — insisted the cart was empty. Add again from the old side, and now the Angular component was the one that couldn't see it. Two views of one cart, each certain the other was wrong.
The service was providedIn: 'root'. In a single Angular app that word — singleton — is a load-bearing wall you get to lean on. What I'd forgotten is that a hybrid app doesn't have one root. It has two.
A hybrid app has two injectors, and they don't share by default
ngUpgrade keeps both frameworks live in the same document, and each brings its own dependency injection. AngularJS resolves services through its $injector; Angular resolves through its own root injector. During the migration both are running side by side, and neither knows about the other's registrations unless you explicitly connect them.
So a service that exists in both frameworks exists twice. The cart had started life as an AngularJS .service('cart', …). When I rebuilt it in Angular I wrote a clean @Injectable({ providedIn: 'root' }) version — and left the old AngularJS registration in place, because un-migrated controllers still injected 'cart' by that name. Every migrated component asked Angular's injector and got the new instance. Every legacy controller asked $injector and got the old one. Both were flawless singletons inside their own framework. There were just two of them, and only one ever heard about any given change.
The mental model that betrayed me was "the service is shared because both sides use it." Using the same name, or even the same class, isn't the same as resolving the same object. Two injectors handed the same blueprint will each build their own — and a duplicated cart, unlike a duplicated date formatter, is the kind of state that contradicts itself in front of the user the moment only one copy gets the update.
Give it one owner, then bridge the instance across
The fix isn't to be cleverer about keeping two copies in sync. It's to have one copy. Pick the framework that owns the service, delete the other registration, and bridge that single instance across the boundary so both sides resolve it.
While you're actively migrating, new code trends toward Angular, so the move that ages best is to let Angular own the service and hand it down to AngularJS. downgradeInjectable registers the Angular service as an AngularJS factory under the old name:
// cart.service.ts — the one owner, rewritten in Angular
@Injectable({ providedIn: 'root' })
export class CartService {
private readonly items = signal<CartItem[]>([]);
readonly count = computed(() => this.items().length);
add(item: CartItem): void {
this.items.update((list) => [...list, item]);
}
}
// bridge it DOWN — AngularJS now resolves the Angular instance, same object
angular
.module('legacyApp')
.factory('cart', downgradeInjectable(CartService));
Now the old 'cart' name and the new CartService are the same instance. The un-migrated header controller that injects 'cart' reads the very signal the Angular component wrote to. Crucially, you delete the legacy .service('cart', …) in the same commit — leaving it is what created the second instance, and a downgraded factory next to a live legacy service just moves the collision one line over.
There's a real alternative, and it's the honest choice when the service is buried deep in legacy code that isn't migrating soon: let AngularJS keep ownership and expose it up to Angular through a factory provider that reaches into $injector:
// expose an AngularJS service UP to Angular
export function cartServiceFactory($injector: angular.auto.IInjectorService) {
return $injector.get('cart');
}
export const cartProvider = {
provide: CartService,
useFactory: cartServiceFactory,
deps: ['$injector'],
};
It works and it resolves one instance — but it points the wrong way for a migration. You're trying to shrink the AngularJS side to nothing, and this keeps a source of truth anchored there, so every consumer you migrate stays tethered to the framework you're removing. I reserve it for services I won't touch until late, not for the ones actively moving.
A third posture drops DI as the sharing mechanism entirely: let the state live at module scope in a plain shared module that both frameworks import, so there's one instance because there's one module, not because an injector was configured correctly. That's the right call when the value has to outlive the framework boundary itself — it's the same lesson I hit sharing a singleton across a micro-frontend seam, where no single injector spans the apps. Inside one hybrid document, though, both injectors are reachable, so I'd rather keep the service in DI — testable, mockable, overridable — than route around it. Use the module-scope escape hatch when DI genuinely can't span the gap, not to avoid learning where the gap is.
A Singleton Is Owned by One Injector, or It Isn't One
providedIn: 'root' promises exactly one instance — inside one injector tree. A hybrid app has two trees for the length of the migration, so the promise quietly covers half the app. The service didn't fork because DI is fragile; it forked because I registered it twice and assumed a shared name meant a shared object. Name the one framework that owns each service, bridge that single instance across the seam, and delete the copy on the other side. One owner, one instance — or the two injectors will keep their own, and let them disagree in front of the user.


