Angular DI: Lazy Module Singleton Duplicated
Logout fails in lazy module: two AuthService instances from providedIn:'root' + providers.
- Components = UI units: template + class + styles. Use OnPush change detection with immutable @Input to cut renders 80%.
- @ViewChild is undefined until ngAfterViewInit. Accessing it in ngOnInit is the #1 timing bug.
- Services use hierarchical DI.
providedIn:'root'creates a singleton in the root injector. If the same service is ALSO listed in a lazy module's providers array, Angular creates TWO instances — state updates in one won't be seen in the other. - HTTP interceptors are middleware. Order matters: auth (adds token), loading (spinner), error (handles 401). Mismatch order breaks functionality.
- Route guards: canMatch prevents lazy module from loading at all. canActivate runs after module loads. Use canMatch for role-based access to admin modules.
- Production killer: RouterModule.forRoot() in a lazy-loaded feature module creates a second Router instance. Navigation events fire twice, views don't update, no error thrown — silent failure for hours.
Think of an Angular app like a large restaurant chain. Each branch (component) handles its own dining room and customer interactions independently. The franchise rulebook (module) groups related branches together and defines what equipment they share. The central kitchen supplier (service) provides ingredients to every branch without each one needing its own farm. The security guard at the door (route guard) checks if you're allowed in. The kitchen manager (interceptor) adds seasoning to every dish before it leaves the kitchen, without the chef knowing. When one branch runs out of a sauce, it calls the supplier — it doesn't try to grow tomatoes itself. That's Angular's architecture in one lunch conversation.
A team I consulted for spent three weeks hunting a memory leak that was crashing their Angular dashboard every four hours in production. Root cause: they were instantiating a new HttpClient inside a component instead of injecting a shared service, spinning up a fresh connection pool on every component mount and never releasing it. Four hours was exactly how long it took to exhaust the browser's connection limit. The fix was twelve characters. The three weeks were pure ignorance of how Angular's dependency injection actually works.
Angular's architecture — components, modules, services, guards, interceptors — isn't ceremony for ceremony's sake. It's a hard answer to a real problem: how do you build a 200-screen enterprise app with a team of 15 developers without it collapsing into a dependency nightmare? Without this structure you get components doing HTTP calls, state management, DOM manipulation, and business logic all in one file. I've seen it. It looks like someone let an intern rewrite jQuery in TypeScript. The separation isn't optional architecture philosophy — it's load-bearing.
After working through this, you'll be able to wire up a component that consumes a singleton service, register it correctly in a feature module or standalone component, know exactly why providedIn: 'root' exists and when NOT to use it, implement HTTP interceptors for auth and error handling, protect routes with guards, and spot the most common architectural mistakes before they hit your code review. You'll also understand why lazy-loaded modules break service singletons if you don't know what you're doing — which is the interview question that separates people who've actually shipped Angular from people who've done the Tour of Heroes.
Components: The Unit of UI — and Why They Must Stay Dumb
A component's only job is to display data and capture user intent. That's it. The moment a component starts making HTTP calls directly, manipulating global state, or containing business logic, you've built a monolith inside a framework that was designed to stop you from doing exactly that.
Before Angular, teams building large SPAs with frameworks like Backbone or early AngularJS (v1) routinely ended up with controllers that were thousands of lines long. Testing was impossible without spinning up the entire app. Reuse was a joke. A component-based architecture forces a hard boundary: the component owns the template and the interaction logic wired to that template, nothing else.
Every Angular component is defined by three things: a TypeScript class (the brain), a template (the face), and styles (the skin). The @Component decorator is what tells Angular's compiler to treat this class as a UI unit. The selector is how you stamp it into other templates. Change detection is how Angular knows when to re-render — and this is where most intermediate developers are still fuzzy. Angular's default change detection strategy (CheckAlways) rerenders the component on every event cycle. Switch to OnPush for any component receiving data via @Input and you cut unnecessary renders dramatically. On a dashboard with 80 components, this is the difference between 60fps and a janky mess.
Lifecycle hooks matter more than most tutorials admit. ngOnInit fires once after the first ngOnChanges — use it for initialization. ngOnChanges fires every time an @Input reference changes — use it to react to parent data updates, but keep it fast. ngAfterViewInit fires after the component's view and all child views are initialized — this is where you can safely access @ViewChild references. ngOnDestroy fires before the component is destroyed — this is your last chance to clean up subscriptions, timers, and event listeners. I've seen teams skip ngOnDestroy entirely and wonder why their app leaks memory. The hook exists for a reason. Use it.
Services and Dependency Injection: Where Your Architecture Actually Lives
Services are where your business logic lives. Not in components, not in utility files, not in a god-object store — in injectable services with a single, clear responsibility. The reason Angular's DI system exists is that it solves a problem that's invisible when your app is small and catastrophic when it isn't: how do you ensure that the same instance of a stateful object is shared across dozens of components without those components knowing about each other?
providedIn: 'root' is the correct default for most services. It registers the service with the root injector, making it a true singleton for the app's lifetime. Angular's tree-shaking will also remove it from the bundle if nothing actually injects it — something the old providers: [] module pattern doesn't give you. The one time you don't want providedIn: 'root' is when you need component-level or lazy-module-level isolation: a form state service that should reset when a component is destroyed, or a polling service that should stop when a feature module is unloaded.
The DI system is hierarchical. Root injector at the top, module injectors in the middle, component injectors at the bottom. When a component asks for a dependency, Angular walks up the injector tree until it finds a provider. If you provide a service at the component level (via the component's providers array), each instance of that component gets its own service instance. I've used this deliberately for a shopping cart draft service that needed to be isolated per product modal — each modal got its own state, destroyed with the modal.
The inject() function (available since Angular 14) is the modern alternative to constructor injection. It's more composable — you can call it inside functions, computed properties, and conditional blocks. I've fully switched to inject() in standalone components. Constructor injection still works, but inject() is cleaner and plays better with TypeScript's strict mode.
Inject() is cleaner than constructor injection. Use it in standalone components.The Lazy Module That Broke the Singleton
@Injectable({ providedIn: 'root' }). The lazy-loaded AdminModule also had providers: [AuthService] because the developer saw an error about missing provider and added it. Angular's DI hierarchy: root injector created one instance. The lazy module's injector created a second instance for components inside that module. The main app used the root instance. The admin dashboard used the lazy instance.
When the user logged out, the main app called clearSession() on the root instance. The lazy instance's state remained unchanged. The admin dashboard's guards checked the lazy instance and saw an active session.
The team never noticed because the app worked fine most of the time — until a logout scenario exposed the divergence.useFactory.
3. Added an Angular rule in CI: ng lint with custom ESLint rule preventing providers arrays in lazy modules for root-provided services.
4. Documented the rule: 'A service with providedIn:'root' must NEVER be listed in the providers array of any lazy-loaded module.'- providedIn:'root' + providers array in lazy module = TWO instances. The lazy instance shadows the root instance for that module's subtree.
- A service that is stateful (auth, user preferences, polling service) cannot be provided in both root and lazy module — the state will diverge.
- If you need a service that resets on module unload, provide it at the component level, not in the lazy module's providers array.
- Write a custom ESLint rule to detect services annotated with providedIn:'root' that appear in any NgModule.providers or Component.providers array.
RouterModule.forRoot() in a lazy-loaded feature module. That creates a second Router instance. Replace with forChild().Key takeaways
RouterModule.forRoot() in a feature module creates a second Router instance and breaks navigation in ways that don't throw errorsCommon mistakes to avoid
9 patternsProviding a root service in a lazy module's providers array — duplicate instance
providedIn: 'root' should remain. For services that must be isolated per module (e.g., form state that should reset when the module unloads), provide them at the component level, not the module level.Calling RouterModule.forRoot() in a lazy-loaded feature module instead of forChild()
NavigationStart, NavigationEnd appear twice in logs). URL updates in browser address bar but the view doesn't change. No error thrown — just silent failure.RouterModule.forRoot(routes) with RouterModule.forChild(routes) in every module except the root AppModule. Only the root module should use forRoot().Accessing @ViewChild in ngOnInit — returns undefined
undefined. No error is thrown — the property just never gets set. The template uses the element, but the component code can't interact with it.ngAfterViewInit. The view (including child components) is guaranteed to be fully initialized there. If the element is conditionally shown (e.g., with *ngIf), use { static: false } and access it after the condition becomes true.Mutating @Input objects with OnPush change detection
this.order.status = 'CONFIRMED', pass a new object: this.order = { ...this.order, status: 'CONFIRMED' }. This creates a new reference, which triggers OnPush change detection.Subscribing to Observables in components without cleanup
async pipe in templates whenever possible. For imperative subscriptions, use takeUntilDestroyed() (Angular 16+) or a destroy$ Subject with takeUntil in ngOnDestroy.Incorrect interceptor order — error interceptor before auth interceptor
AuthInterceptor (adds token), LoadingInterceptor (spinner), ErrorInterceptor (handles errors). Order is determined by registration order in the providers array.Importing BrowserModule in a feature module
BrowserModule is only for the root AppModule. Use CommonModule in every feature and shared module. CommonModule provides NgIf, NgFor, and other core directives.Using *ngFor on large lists without trackBy
trackBy to ngFor on data coming from observables or HTTP responses: ngFor="let item of items; trackBy: trackById". Provide a function that returns a stable, unique identifier.Hardcoding API URLs in services instead of using environment files
localhost:3000 in staging/production. Deploying to a new environment requires code changes and a redeploy.environment.ts and environment.prod.ts. Use angular.json file replacements to swap them at build time. Reference environment.apiBase in services.Interview Questions on This Topic
Angular's dependency injector is hierarchical. If a service is provided at both the root injector and inside a lazy-loaded module's providers array, how many instances get created, and how does Angular decide which instance to inject into a component inside that lazy module?
providedIn: 'root' service in a lazy module's providers array.Frequently Asked Questions
That's Advanced JS. Mark it forged?
4 min read · try the examples if you haven't