Refactoring Angular Code: From NgModules to Standalone, Constructor Injection to inject(), RxJS to Signals
The safe way to modernize an old Angular component is to pin its behavior with tests first, run the official migration schematics feature by feature with --path, each in its own commit, and convert by hand, under the tests, the data flow the schematics can't touch. The post migrates a CheckoutComponent with eight schematics plus a manual switchMap and signals step, showing with real output prune-ng-modules breaking the build, a stream ending after an error and a doubled request.
Version note: The code in this post was built and run with Angular 22.2 (CLI 22.2.7), TypeScript 6.0 (Angular 22.2 projects require TypeScript 6.0) and RxJS 7.8; the tests were run with Vitest 5.1.
Guess First: Eight Schematics, How Many Red Tests?
Bookstore's Angular application was written in the Angular 12 era and upgraded version by version to 22 with ng update. The code compiles, but it is still written the old way. The CheckoutComponent on the payment page is a typical example: it is declared in an NgModule, uses constructor injection, has *ngIf and *ngFor in its template, asks for the shipping fee with a nested subscribe once the cart arrives, and collects its subscriptions by hand:
export class CheckoutComponent implements OnInit, OnDestroy {
@ViewChild('couponInput') couponInput?: ElementRef<HTMLInputElement>;
cart?: Cart;
shipping?: number;
loading = true;
error?: string;
private subscriptions = new Subscription();
constructor(
private cartService: CartService,
private shippingService: ShippingService,
) {}
ngOnInit(): void {
this.subscriptions.add(
this.cartService.getCart().subscribe((cart) => {
this.cart = cart;
this.subscriptions.add(
this.shippingService.quote(cart.postalCode, cart.weightKg).subscribe({
next: (amount) => {
this.shipping = amount;
this.loading = false;
},
error: () => {
this.error = 'Shipping quote failed.';
this.loading = false;
},
}),
);
}),
);
}
ngOnDestroy(): void {
this.subscriptions.unsubscribe();
}
}
The component's decorator also has a changeDetection: ChangeDetectionStrategy.Eager line. We didn't write it: in Angular 22 the default for components became OnPush, and the change-detection-eager migration of ng update added this line to every component to keep the old behavior. The goal is to move this component to standalone, inject(), built-in control flow and signals with Angular's official migration schematics. We'll run eight schematics. After how many of them do you think the build or the tests turn red?
Tests First: Pinning Today's Behavior
Schematics change code automatically, but automatic doesn't mean correct. So the first commit is tests. We replace CartService and ShippingService with Subjects whose answers arrive whenever the test decides, and pin the text that appears on the screen:
it('shows the lines and the total once the cart and the quote arrive', () => {
expect(text()).toBe('Loading...');
carts.next(cart('34000'));
quotes.get('34000')!.next(59.9);
fixture.detectChanges();
expect(text()).toBe('2 x Dune | Shipping: 59.9 | Total: 959.9 | Place order');
});
// Odd but current: the cart stays visible next to the error and the total has no shipping.
it('shows an error next to the cart when the quote fails', () => {
carts.next(cart('34000'));
quotes.get('34000')!.error(new Error('503'));
fixture.detectChanges();
expect(text()).toBe('Shipping quote failed. | 2 x Dune | Shipping: | Total: 900 | Place order');
});
I didn't write the second test's expectation; I took it from the output of the first run. I didn't expect the cart to be visible in the error case; a characterization test records what the code does today, not what it should do. The same file has two more tests: one checks that the subscription to the shipping request closes when the component is destroyed, and one deliberately pins a known bug. If the cart arrives twice in a row (as when the user changes the address) and the old cart's shipping answer arrives after the new one, the old cart's fee stays on the screen. The test expects this buggy behavior; the fix will be a separate commit.
Standalone: Two Steps and a Surprise
Every schematic can be limited to a single folder with --path; that is how you run the work feature by feature. The standalone migration has three modes, and the first one makes the components standalone:
$ ng generate @angular/core:standalone --path=src/app/checkout --mode=convert-to-standalone
🎉 Automated migration step has finished! 🎉
IMPORTANT! Please verify manually that your application builds and behaves as expected.
UPDATE src/app/checkout/checkout.component.ts (1883 bytes)
UPDATE src/app/checkout/order-summary.component.ts (447 bytes)
UPDATE src/app/checkout/checkout.module.ts (367 bytes)
The diff is as expected: the standalone: false line is gone, the directives the template uses are written into the component's own imports list (NgIf, NgFor, NgClass, OrderSummaryComponent), and the module now imports the components instead of declaring them. Build green, four tests green, commit. The second mode deletes modules that are no longer useful:
$ ng generate @angular/core:standalone --path=src/app/checkout --mode=prune-ng-modules
DELETE src/app/checkout/checkout.module.ts
UPDATE src/app/app-module.ts (470 bytes)
The schematic deleted CheckoutModule and also removed it from AppModule's imports list; it didn't add CheckoutComponent in its place. The file changed even though it is outside the --path we gave. The build told us right away:
✘ [ERROR] NG8001: 'app-checkout' is not a known element:
1. If 'app-checkout' is an Angular component, then verify that it is part of this module.
...
✘ [ERROR] TS2307: Cannot find module './checkout.module' or its corresponding type declarations. [plugin angular-compiler]
The second error comes from the test file, which imported the module too. The fix is two lines: import CheckoutComponent in AppModule and in the test. This was the first red of the eight schematics, and it showed that the build is as much part of the safety net as the tests. The third mode (standalone-bootstrap) moves the application to bootstrapApplication; since the rest of the application is still NgModule-based, we'll run it when all features are done. The standalone APIs themselves are covered in the Angular series post "Standalone APIs in Angular 15: bootstrapApplication, provideRouter and Apps Without NgModule".
Control Flow, inject() and the Signal APIs
The next schematics had fewer surprises, but each diff had something to look at. The control flow migration turned *ngIf and *ngFor into @if and @for and removed the now unused NgIf and NgFor from imports:
-<div *ngIf="loading" class="loading">Loading...</div>
+@if (loading) {
+ <div class="loading">Loading...</div>
+}
...
- <li *ngFor="let line of cart.lines">{{ line.quantity }} x {{ line.title }}</li>
+ @for (line of cart.lines; track line) {
+ <li>{{ line.quantity }} x {{ line.title }}</li>
+ }
@for requires a track expression, and to keep the old code's behavior the schematic chose the object reference: track line. Since new objects arrive every time the cart is refreshed, this means every row is recreated in the DOM. The right key is the book's ISBN; we put this small change in a commit separate from the schematic commit, so the reviewer can see what came from the machine and what came from a person.
The inject migration turned constructor parameters into fields: private cartService = inject(CartService);. The fields aren't readonly; we added that by hand later. Then came three schematics for the signal APIs:
$ ng generate @angular/core:signal-input-migration --path=src/app/checkout
Successfully migrated to signal inputs 🎉
-> Migrated 1/1 inputs.
$ ng generate @angular/core:output-migration --path=src/app/checkout
Successfully migrated to outputs as functions 🎉
-> Migrated 1 out of 1 detected outputs (100.00 %).
$ ng generate @angular/core:signal-queries-migration --path=src/app/checkout
Successfully migrated to signal queries 🎉
-> Migrated 1/1 queries.
In OrderSummaryComponent, @Input() total = 0 is now readonly total = input(0), @Output() confirm = new EventEmitter<void>() is now readonly confirm = output<void>(), and {{ total }} in the template became {{ total() }}. @ViewChild turned into viewChild(), and the focusCoupon method that uses it was updated to this.couponInput()?.nativeElement.focus(). This is the real value of the schematics: they change not only the declaration but also the references. Because signal inputs are read-only, the schematic can't safely migrate an input that application code assigns to somewhere, and skips it; that is why the "Migrated 1/1" ratio in the output is the first thing to look at in review. The --insert-todos option adds a comment next to each skipped input explaining why.
The last two are template clean-up: ngclass-to-class turned [ngClass]="{ 'free-shipping': shipping === 0 }" into [class.free-shipping]="shipping === 0" and removed NgClass from imports. That's why cleanup-unused-imports, which ran after it, found nothing to do:
$ ng generate @angular/core:cleanup-unused-imports
Scanning for unused imports using tsconfig.app.json
Schematic could not find unused imports in the project
Nothing to be done.
This schematic doesn't accept --path (Error: Unknown argument: path) and looks at the whole project; in a large project it is best left for last, with its diff reviewed separately. The APIs themselves are covered in the Angular series posts "What's New in Angular 17: @if, @for, @defer and the New Application Builder" and "What's New in Angular 17.x: input(), model(), output() and Signal Queries".
The Answer: One Red Build, but the Job Isn't Done
Of the eight schematics, only one, prune-ng-modules, broke the build; the tests never turned red. But looking at the component, the job doesn't look done: the nested subscribe, the hand-collected subscriptions, ngOnInit and ngOnDestroy and Eager change detection are all still there. Schematics change syntax and APIs; they don't change the design of the data flow. The rest is done by hand, and that is where the tests really pay off.
The Part Schematics Can't Do: Nested subscribe
The well-known replacement for a nested subscribe is switchMap: when a new cart arrives, it cancels the previous cart's shipping request. That also fixes the old-cart bug we pinned on purpose. But it is a behavior change; so I first made a plain conversion and ran the tests:
this.cartService
.getCart()
.pipe(
tap((cart) => (this.cart = cart)),
switchMap((cart) => this.shippingService.quote(cart.postalCode, cart.weightKg)),
)
.subscribe({ next: /* ... */, error: /* ... */ });
FAIL |bookstore-web| src/app/checkout/checkout.component.spec.ts > CheckoutComponent > quotes the next cart after a failed quote
TypeError: Cannot read properties of undefined (reading 'next')
FAIL |bookstore-web| src/app/checkout/checkout.component.spec.ts > CheckoutComponent > keeps the quote of an older cart when it arrives last
AssertionError: expected '2 x Dune | Shipping: 69.9 | Total: 96…' to contain 'Shipping: 59.9'
The second red is the expected result: the old bug is fixed, and the test broke because it expected the old bug. The first one is a regression. I added that test after the schematics and before starting the manual conversion: in the old code, even if the shipping request failed, a new request was made for the next cart, because the error only ended the inner subscription. With a plain switchMap, the error in the inner stream travels to the outer stream and the cart stream ends completely; even if the user fixes the address, the shipping fee is never calculated again. In async code, "writing the same thing shorter" can quietly change error and cancellation behavior.
The fix is to catch the error in the inner stream. This commit states two behavior changes openly: a late answer for an old cart is now ignored, and the error message goes away once a successful answer arrives after an error. The expectation of the test that pinned the old bug changed in the same commit:
switchMap((cart) =>
this.shippingService.quote(cart.postalCode, cart.weightKg).pipe(
map((amount) => ({ amount, error: undefined })),
catchError(() => of({ amount: undefined, error: 'Shipping quote failed.' })),
),
),
Moving to Signals: toSignal and computed
Once the flow is right, moving the state to signals is mechanical work. The component's fields become toSignal and computed, and ngOnInit, ngOnDestroy and Subscription go away; toSignal closes the subscription itself when the component is destroyed. The Eager line is deleted too, and the component runs with OnPush, Angular 22's default:
export class CheckoutComponent {
private readonly cartService = inject(CartService);
private readonly shippingService = inject(ShippingService);
readonly couponInput = viewChild<ElementRef<HTMLInputElement>>('couponInput');
// One request for the cart, shared by both signals below.
private readonly cart$ = this.cartService.getCart().pipe(shareReplay({ bufferSize: 1, refCount: true }));
readonly cart = toSignal(this.cart$);
private readonly quote = toSignal(
this.cart$.pipe(
switchMap((cart) =>
this.shippingService.quote(cart.postalCode, cart.weightKg).pipe(
map((amount) => ({ amount, error: undefined })),
catchError(() => of({ amount: undefined, error: 'Shipping quote failed.' })),
),
),
),
);
readonly loading = computed(() => this.quote() === undefined);
readonly shipping = computed(() => this.quote()?.amount);
readonly error = computed(() => this.quote()?.error);
readonly itemsTotal = computed(
() => this.cart()?.lines.reduce((sum, l) => sum + l.quantity * l.unitPrice, 0) ?? 0,
);
}
The shareReplay line is a detail that is easy to miss. When two toSignal calls subscribe to the same Observable, HttpClient sends two separate requests unless the stream is shared. Before this refactoring I added a test checking that the cart is requested once; when I deleted the line and tried, the test caught it:
FAIL |bookstore-web| src/app/checkout/checkout.component.spec.ts > CheckoutComponent > requests the cart once
AssertionError: expected 2 to be 1 // Object.is equality
We could have built the same flow with rxResource, which is stable as of Angular 22; passing the cart as a signal and writing the shipping request into its stream also takes care of cancelling the previous request. We chose toSignal here because the flow was already in RxJS and the only change was connecting it to a signal.
When Not to Move to Signals
Not every Observable needs to become a signal. CartService and ShippingService still return Observables, and that is right: an HTTP request is a cancellable operation that produces a single value, and RxJS expresses it well. Signals are good for state the template reads; not for time-based event streams. Turning a typeahead built with debounceTime, distinctUntilChanged and switchMap into signals doesn't shorten the code, it lengthens it. The boundary is usually this: the flow is built in RxJS and converted to a signal at the very end, to be handed to the template. The details of this boundary are in the upcoming RxJS and State Management in Angular series.
OrderSummaryComponent is a good example too. The schematics moved it to input() and output() and we stopped there; it holds no state, it only shows a value and emits an event. Converting it further wouldn't gain anything.
A Gradual Migration: Feature by Feature
The whole migration is fourteen commits, each one green:
$ git log --oneline
2b31234 refactor: CheckoutComponent state as signals (toSignal, computed), no manual subscriptions, OnPush
a182e67 test: pin that the cart is requested once
a6f3ff6 fix: quote only the latest cart (switchMap); a failed quote no longer ends the stream and a later quote clears the error
761454d test: pin that a new cart is quoted after a failed quote
a213b05 refactor: class binding instead of NgClass (ng g @angular/core:ngclass-to-class)
c1c4983 refactor: viewChild() signal query in CheckoutComponent (signal-queries-migration)
6af7f48 refactor: input() and output() in OrderSummaryComponent (signal-input-migration, output-migration)
ccd67b8 refactor: inject() instead of constructor injection (ng g @angular/core:inject)
466c891 refactor: track cart lines by isbn instead of object identity
aa5383e refactor: built-in control flow in checkout (ng g @angular/core:control-flow)
d9d55b3 refactor: remove CheckoutModule (prune-ng-modules) and import CheckoutComponent where the module was used
ba8f363 refactor: CheckoutComponent and OrderSummaryComponent standalone (ng g @angular/core:standalone convert-to-standalone)
ccb6ebb test: characterization tests for CheckoutComponent
6f27d13 legacy: CheckoutComponent in CheckoutModule (zone.js, Eager)
Each schematic is in its own commit, and the commit message contains the command that was run. The reviewer can look at the machine-generated diff with one eye and at the human-written one with another. This separation is the Angular counterpart of the "bulk automated fixes in their own commit" rule from the third post.
In a real project this work is done not with one component but with dozens of feature folders. Finishing and merging one feature with --path, then moving to the next, keeps reviews small and limits the impact of a schematic that does something unexpected to that feature. During this time the codebase will be mixed: some components standalone and signal-based, some in NgModules. A written team rule makes this period easier: new code is written only with standalone, inject() and the signal APIs; if an old component is touched for a feature, that feature's migration is done first as a separate PR. Checking such rules automatically is the topic of the upcoming Coding Conventions and Team Standards series. When all features are done, AppModule goes away with standalone-bootstrap, and as more components run on OnPush and signals, removing zone.js comes up too. A comparison of Angular 22's defaults with an old project is in the Angular series post "Angular 22: OnPush by Default, Stable Signal Forms and a File-by-File Comparison with Angular 12".
Common Pitfalls
Applying a migration to the whole project with one command and opening a single huge PR. A schematic run without --path touches hundreds of files, and nobody really reads that diff. The prune-ng-modules surprise in this post would have shown up in a hundred different places in a project with a hundred modules.
Merging schematic output without review. Schematics try to preserve behavior, but that doesn't always mean producing the best code: track line works but rebuilds the DOM on every refresh; inject() fields arrive without readonly. If the ratio in a schematic's output isn't 1/1, don't merge before finding out why something was skipped.
Passing over places where async behavior changes without tests. We saw it twice in this post: a plain switchMap ended the stream after an error, and toSignal without shareReplay doubled the request. Both compile and pass happy-path tests; what caught them were tests that pin cases like errors, cancellation and the number of requests.
Mixing a behavior fix into a refactoring. Fixing the old-cart bug is a good thing, but had it come inside a commit titled refactor:, the reviewer wouldn't have questioned why a test's expectation changed. The fix is in its own commit with a fix: title.
Turning everything into signals. Observables are still the right tool for HTTP and time-based streams; signals are for the state the template reads.
A Small Experiment
★☆☆ Pick a feature folder in your own Angular project and run ng generate @angular/core:control-flow --path=.... Look at the generated track expressions: how many are object references and how many are real ids?
★★☆ Find a component that uses a nested subscribe. First write a test that pins the case where the inner request fails and the outer stream produces a second value, then convert the code to switchMap. What does the test say?
★★★ Rewrite the shipping part of this post's CheckoutComponent with rxResource: give the cart as params and the shipping request as stream. Do the same six tests pass unchanged? If one doesn't, where does the behavior diverge?
So far in this series we have worked on six of Bookstore's legacy parts. Which one to invest in first, though, was always our choice. In the last post we base that choice on data: with git log and a small C# script we'll compute churn and Cognitive Complexity for the Bookstore repo to find hotspots, look at change coupling, and talk about how to explain refactoring to the Product Owner in terms of the cost of change, lead time and failure rate.