Shahathir (•◡•)

24 · Batu Caves, Selangor, Malaysia · 🇲🇾

professionally distracted

My Career Journey

  1. Financial Risk Group logo

    Financial Risk Group

    1yrs 3mos

    Assistant Software Developer

    Jun 2025 – Present

  2. Estee Lauder Companies logo

    Estee Lauder Companies

    6mos

    Software Engineer Intern

    Sep 2024 – Mar 2025

Tools & Platforms

TypeScript
JavaScript
Java
Python
PHP
Go
HTML5
CSS3
React
Next.js
Vite
Angular
Redux
React Router
Tailwind CSS
shadcn/ui
Material UI
Sass
Bootstrap
React Native
Node.js
TypeScript
JavaScript
Java
Python
PHP
Go
HTML5
CSS3
React
Next.js
Vite
Angular
Redux
React Router
Tailwind CSS
shadcn/ui
Material UI
Sass
Bootstrap
React Native
Node.js
Bun
Django
FastAPI
Spring Boot
Java Servlets
Express
PostgreSQL
MySQL
MS SQL Server
SQLite
Appwrite
Docker
AWS
Cloudflare
Nginx
Vercel
Netlify
DigitalOcean
Git
DBeaver
Postman
Bun
Django
FastAPI
Spring Boot
Java Servlets
Express
PostgreSQL
MySQL
MS SQL Server
SQLite
Appwrite
Docker
AWS
Cloudflare
Nginx
Vercel
Netlify
DigitalOcean
Git
DBeaver
Postman

Words I Live By

Shahathir is currently not listening to anything
Shahathir is currently not listening to anything

2026 © shahathir.me

Changelogs · Old site

  • AboutAbout
  • ThoughtsThoughts
  • TILTIL
  • BookmarksBookmarks
  • ExperienceExperience
  • ProjectsProjects
  • AccoladesAccolades
  • PhotographyPhotography
  • SongsSongs
  • StatsStats
  • UsesUses
  • ChatChat
  • Resume BuilderResume Builder
  • AboutAbout
  • ThoughtsThoughts
  • TILTIL
  • BookmarksBookmarks
  • ExperienceExperience
  • ProjectsProjects
  • AccoladesAccolades
  • PhotographyPhotography
  • SongsSongs
  • StatsStats
  • UsesUses
  • ChatChat
  • Resume BuilderResume Builder
January 12, 2026

What made a two-major Angular upgrade survivable

Taking a large NgModule app across two majors to fully standalone came down to two things — letting the schematics do the boring bulk, and remembering that "modernize" is opt-in.

I took a large, NgModule-based Angular app across two major versions and out to fully standalone — around forty module and routing-module files gone — without a rewrite and while keeping it shippable at each step. Two realizations did most of the work.

Let the schematics do the boring bulk. ng update plus the official migrations carry the mechanical mass: ng generate @angular/core:standalone converts components, prunes modules, then drops the now-default standalone: true; the control-flow migration turns *ngIf/*ngFor/*ngSwitch into @if/@for/@switch. Then the one manual move is standalone bootstrap — bootstrapModule(AppModule) becomes bootstrapApplication(AppComponent, { providers }), with each root-module provider becoming a provide* function:

bootstrapApplication(AppComponent, {
  providers: [
    provideZoneChangeDetection(),                        // chose to STAY zone-based
    importProvidersFrom(LegacyWidgetModule.forRoot()),   // bridge for module-only libs
    provideRouter(appRoutes),
    provideHttpClient(withInterceptorsFromDi()),         // keep class interceptors
    { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
    provideAppInitializer(async () => { await inject(RuntimeConfig).load(); }),
  ],
});

importProvidersFrom() is worth calling out: it's the bridge for third-party libraries that still ship only an NgModule. Reaching for it isn't a failure to be "fully standalone" — it's the sanctioned escape hatch, and the app is standalone at its own level while those libs stay module-backed underneath.

"Modernize everything" is the trap; modernizing is opt-in. This is the realization that kept the PR from ballooning into a risky rewrite. You do not have to convert every guard and interceptor to the functional style. The app happily runs a mix — a functional CanDeactivateFn sits right next to class CanActivate guards, and the HTTP interceptors stayed DI-class-based via withInterceptorsFromDi() rather than being rewritten to functional ones. Angular lets old and new coexist on purpose; leaning on that is what makes the upgrade a series of shippable steps instead of a big bang.

@Component({
  selector: "app-widget",
  imports: [/* only what's used — CommonModule is dead weight once you're on @if/@for */],
  template: `
    @if (user(); as u) { <h2>{{ u.name }}</h2> }
    @for (row of rows(); track row.id) { <app-row [row]="row" /> }
  `,
})
export class WidgetComponent {
  private data = inject(DataService); // was a constructor parameter
}

The second major bump was deliberately tiny, and that's the tell that the approach worked: a router API rename, an automated prune of now-unused CommonModule from standalone imports arrays (with @if/@for it's dead weight), an added provideZoneChangeDetection() — i.e. an explicit choice to stay on Zone.js rather than chase zoneless mid-migration — and a tsconfig cleanup. Nearly the entire diff was those four mechanical patterns.

One honest caveat I'd want a reader to have: a version bump does not migrate you to signal inputs. Signals, input(), and output() are everywhere in the codebase now, but that adoption happened separately and incrementally, on its own schedule — not as a side effect of the upgrade. Conflating "we're on the new Angular" with "we're fully signals-based" sets a false expectation about how much a major upgrade actually changes.

The through-line: a big Angular upgrade is survivable precisely because the framework doesn't force you to modernize everything at once. Run the schematics for the tedious bulk, bridge the stragglers with importProvidersFrom, leave working class-based guards and interceptors alone, and let the old and new styles coexist until you choose to migrate each one.