skip to content

A legacy Angular SharedModule imports and re-exports forty components, three form and common modules, and provides services; what problems does it cause and how would you break it up?

level: seniorimportance: should knowfreq 42%

answer

  1. providers duplicated in lazy injectors
  2. implicit, app-wide template scope
  3. services first, then components
  4. SCAM then standalone

basics

~20 s

Its providers are re-registered in every lazy module's injector, duplicating singletons, and its huge re-export scope hides what each template uses. Move services to providedIn: 'root' first, then split components into SCAMs or standalone components imported directly.

solid answer

~40 s

The worst problem is `providers` on a shared module: every lazily loaded feature that imports it gets its own child-injector instances, so supposed singletons diverge. The re-exports give every importing template an enormous implicit scope, so no one can tell what a component really uses, removals need codebase-wide searches, and selector collisions and circular imports appear. I would not lead with bundle size, because AOT only references what each template actually uses. The exit is incremental: move services to `providedIn: 'root'` and delete the module's providers; stop re-exporting framework modules wholesale; split components into single-component modules (SCAM) or, better, convert them to standalone and import them where used; delete the empty shell last.

code

ts · 10 lines
ts
import { Injectable, signal } from '@angular/core';

@Injectable({ providedIn: 'root' })
export class NotificationService {
  readonly messages = signal<string[]>([]);

  push(message: string): void {
    this.messages.update((list) => [...list, message]);
  }
}

go deeper

for a junior

Know the feature, core and shared module conventions and that shared modules should hold components and pipes, not services.

for a middle

Explain why providers in a shared module duplicate under lazy loading, and what SCAM changes compared with one big shared module.

for a senior

Plan a safe, incremental break-up: services to providedIn root first, then per-component extraction into standalone, with a way to verify nothing lost its scope.

for a principal

Sequence the break-up against team ownership and release risk, deciding which shared pieces become a published library and which dissolve into features.

## The scenario A long-lived Angular codebase has a `SharedModule` that every feature module imports. Over the years it has grown to import and re-export `CommonModule`, `FormsModule`, `ReactiveFormsModule`, forty components, a dozen pipes — and it also lists several services in its `providers`: ```ts @NgModule({ declarations: [...FORTY_COMPONENTS, ...PIPES], imports: [CommonModule, FormsModule, ReactiveFormsModule], exports: [CommonModule, FormsModule, ReactiveFormsModule, ...FORTY_COMPONENTS, ...PIPES], providers: [NotificationService, FeatureFlags], }) export class SharedModule {} ``` This was the recommended convention for years: a **core** module imported once for singletons, **feature** modules per business area, and a **shared** module for common building blocks. The problems come from letting the shared module grow without limits. ## What goes wrong ### 1. Duplicated services in lazy features `SharedModule` has `providers`. Every **lazily loaded** feature module that imports it registers those providers again in the lazy module's own child injector. Each lazy feature therefore gets its **own** `NotificationService` and `FeatureFlags`. Symptoms: notifications raised in one feature never appear in the app shell; a flag toggled at runtime is seen by some screens and not others. A shared module should hold declarables only; services belong in `providedIn: 'root'` or a core module imported once. ### 2. Nobody can tell what a component actually uses Every declared template in every importing module can use every exported selector. Removing a component from `SharedModule` requires searching the whole codebase, and adding one risks **selector collisions** with feature components. Dependencies are implicit, so reviews and refactors are slow. ### 3. Tight coupling and circularity Features depend on the shared module, and shared components start depending on feature services. It becomes easy to create circular imports, and a change to a shared component's selector or inputs can affect the templates of every module that imports the shared module. ### 4. A myth to avoid A common claim is that a big shared module "adds all forty components to every bundle". With the AOT compiler, each component's generated definition references only the directives and pipes its template **actually uses**, so the compiled template dependencies are not the whole scope. The strongest arguments are about **providers, coupling and clarity**, not a blanket bundle-size claim. ## How to break it up A practical, incremental order: 1. **Move the services out first.** Change `NotificationService` and `FeatureFlags` to `@Injectable({ providedIn: 'root' })` and delete `SharedModule.providers`. This fixes the duplicate-instance bugs immediately and is low-risk. 2. **Stop re-exporting framework modules wholesale.** Let each module import what it uses instead of inheriting `ReactiveFormsModule` everywhere. 3. **Split by cohesion, or go to one declarable per unit.** The pre-standalone pattern for this was **SCAM** — *Single Component Angular Module*: each component gets a tiny module that declares and exports only that component and imports only what its template needs. Consumers import exactly the pieces they use. 4. **Convert to standalone.** Standalone components are the modern form of SCAM: the component lists its own `imports`, and consumers import the component class directly. Standalone components can be imported into existing NgModules, so the shared module can shrink component by component rather than in one big change. 5. **Delete the shell.** When `SharedModule` exports nothing that is still used, remove it. | Structure | Scope of a template | Where services live | Status | |---|---|---|---| | Big `SharedModule` | Everything it exports | Often in its `providers` (buggy with lazy loading) | Legacy anti-pattern | | Feature / core / shared split | Per module | Core module, imported once | Legacy convention | | SCAM | One component's needs | `providedIn: 'root'` | Pre-standalone bridge | | Standalone | The component's own `imports` | `providedIn: 'root'` or `provideX()` | Current default | ## What interviewers listen for - Recognising **providers in a shared module** as the real bug with lazy loading. - Explaining **implicit scope** as the maintainability cost. - Not overstating the bundle-size effect. - A **step-by-step** exit that ships value early (services first) and ends at standalone. ## Verifying that nothing lost its scope Each extraction step removes selectors from some templates' scope, so the migration needs a safety net. The AOT compiler provides it: a template that uses a selector no longer in scope fails the build with `NG8001` ('... is not a known element'), and an unknown property binding fails with `NG8002`. Two cautions keep that net intact: - Do not add `CUSTOM_ELEMENTS_SCHEMA` or `NO_ERRORS_SCHEMA` to modules during the break-up; the first silences these errors for dashed custom tags, the second for every element, and both hide the breakage you are looking for. - Run the production build, not only the dev server, after each step, and keep each step small enough to review.

  • What exactly is a SCAM, and why was it popular before standalone components?
    A Single Component Angular Module declares and exports exactly one component and imports only what that component's template needs. It made dependencies explicit and let consumers import one piece at a time, which is what standalone components now provide without the extra module.
  • Is it safe to keep a CoreModule with providers during the migration?
    Yes, if it is imported only once by the root module, so its providers land in the root injector. Many teams add a constructor guard that throws when the module is loaded a second time. Longer term, `providedIn: 'root'` services or `provideX()` functions replace it.

saying these in an interview costs you the question

  • A shared module's providers are singletons no matter who imports it
  • Every component in SharedModule is compiled into every importer's bundle
  • Re-exporting CommonModule everywhere has no cost to maintainability
  • You must convert the whole SharedModule to standalone in one release
  • Standalone components cannot be used inside existing NgModules