# Modern Angular Architecture: From NgModules to Standalone Components

NgModules were Angular's answer to a real problem: how do you organize a large application into logical units, share dependencies, and lazy-load chunks of functionality? The answer worked. It was also verbose, confusing to newcomers, and responsible for some of the most baffling error messages in frontend development.

Standalone components — stable since Angular 15, the default since Angular 17 — solve the same problems with significantly less ceremony. I've migrated a production application through this transition and built new ones from scratch with the standalone model. Here's what actually matters.

## The mental model shift

In the NgModule world, a component is a member of a module. The module declares what the component can use. You want to use `RouterLink` in a component? Add `RouterModule` to the module's imports. You want to use another component? Either declare it in the same module or import the module that exports it.

This creates a layer of indirection that makes components non-portable. A component's dependencies are defined somewhere else, by someone else, possibly a long time ago. New team members spend their first weeks asking "why can't I use X here" and the answer is always somewhere in a module file.

In the standalone model, the component owns its dependencies:

```typescript
@Component({
  selector: 'app-dashboard',
  templateUrl: './dashboard.component.html',
  standalone: true,
  imports: [NgIf, NgFor, NgClass, RouterLink],
})
export class DashboardComponent { }
```

Everything the template needs is declared right here. You can open this file cold and know exactly what it depends on without hunting through module trees. That's not a small thing — it compounds across a codebase.

## inject() over constructor injection

Standalone components pair naturally with the `inject()` function, which lets you inject dependencies without a constructor parameter list:

```typescript
// Old pattern
export class MyComponent {
  constructor(
    private readonly router: Router,
    private readonly authService: AuthService,
    private readonly contentService: ContentService,
  ) {}
}

// New pattern
export class MyComponent {
  private readonly router         = inject(Router);
  private readonly authService    = inject(AuthService);
  private readonly contentService = inject(ContentService);
}
```

This isn't just a style preference. `inject()` works in any injection context — component constructors, service factories, route guards — which means you can extract reusable injection logic into plain functions without creating a service class for it. It also makes the dependencies easier to read at a glance since each one is on its own line with its type visible.

## Signals: the reactive layer that finally makes sense

Angular 16 introduced signals as a new reactive primitive, and they fit the standalone model naturally. Where you'd previously reach for `BehaviorSubject` or complex `async` pipe chains, signals give you a simpler model:

```typescript
export class AdminComponent {
  readonly inquiries  = signal<Inquiry[]>([]);
  readonly activeTab  = signal<'inquiries' | 'visitors'>('inquiries');
  readonly loading    = signal(true);

  // computed() derives state — recalculates only when dependencies change
  readonly inquiryCount = computed(() => this.inquiries().length);
}
```

The key insight: a signal is just a value with change notification built in. You read it by calling it like a function — `this.loading()` — and write it with `.set()` or `.update()`. Angular tracks which signals a template reads and re-renders only when those specific signals change.

For auth state in particular, signals are a significant improvement. Rather than maintaining a subscription and manually updating component state, you expose a signal from a service and components derive from it with `computed()`:

```typescript
// Service
readonly session = signal<SessionInfo>(UNAUTHENTICATED);

// Component — updates automatically whenever session changes
readonly navConfig = computed(() => this.buildConfigForRole(this.authService.session()));
```

No subscriptions. No `ngOnDestroy`. No memory leaks from forgotten unsubscribes.

## Lazy loading without modules

Lazy loading used to require a routing module and a `loadChildren` callback that imported it. Now it's a single line:

```typescript
// Before
{
  path: 'dashboard',
  loadChildren: () => import('./dashboard/dashboard.module').then(m => m.DashboardModule)
}

// After
{
  path: 'dashboard',
  loadComponent: () => import('./dashboard/dashboard.component').then(c => c.DashboardComponent)
}
```

The bundle split is identical. The module is gone. For route groups you still have `loadChildren`, but it now accepts an array of routes rather than a module.

## Migration strategy that actually works

If you're migrating an existing app, go leaf-first. Start with components that have no children of their own — the deepest nodes in the component tree. Add `standalone: true` and move their dependencies into the component's `imports` array. Then work inward toward shared components, then feature components, then the root.

The Angular CLI has a migration schematic that handles most of this automatically:

```bash
ng generate @angular/core:standalone
```

Run it in stages — it gives you options for converting components, removing unnecessary NgModule imports, and bootstrapping as standalone. Use `git diff` aggressively between each stage to review what changed.

**CommonModule imports.** You'll find `CommonModule` in a lot of module import lists because it's the easy way to get `NgIf`, `NgFor`, and the async pipe. In standalone components, import only what you need — `NgIf`, `NgFor`, `AsyncPipe` — individually. It's more verbose initially but makes tree-shaking more effective.

**Shared services don't change.** Services with `providedIn: 'root'` work exactly the same. You don't need to touch them. The standalone migration is a component-level change, not a service-level one.

**HTTP interceptors move to bootstrapApplication.** If you use HTTP interceptors, they're now registered with `withInterceptors()` in the `bootstrapApplication` call rather than in a module provider array. Easy to miss, and it fails silently if you forget.

**Third-party libraries may lag.** Some Angular libraries still export NgModules and haven't published standalone-compatible exports. You can still import a module into a standalone component's `imports` array — it works — but check whether newer versions of the library offer standalone APIs before importing the whole module.

## Is it worth the migration effort?

For a new project: yes, unambiguously. Build standalone from the start. The mental model is simpler, the tooling assumes it, and new Angular features are being designed around it.

For an existing app: it depends on the size and stability of the codebase. A large, stable NgModule-based app that isn't causing problems doesn't urgently need migration. A growing codebase with new features being added regularly will benefit from the migration — but do it incrementally, not all at once.

At M²S² Engineering Group, we help teams make exactly these kinds of decisions — whether to migrate, how to sequence it, and how to do it without disrupting ongoing delivery. If you're navigating a modernization and want to talk it through, [let's talk](/contact).

---

*Originally published at [m2s2.io](https://m2s2.io/blog/angular-standalone-architecture)*
