Angular for Enterprise Apps: Components, Services, DI, Signals and Standalone Components

Key takeaways

A structured tour of modern Angular: standalone components, services and dependency injection, HTTP, routing, reactive forms, and where Signals and RxJS each fit, with the errors that trip up newcomers and when Angular's structure pays off.

What this post covers

Angular is an opinionated, batteries-included framework: routing, forms, HTTP, dependency injection, testing tools and a CLI all come from the same team and are versioned together. This post walks through a small user-management app in modern Angular style (standalone components, inject(), the built-in @if/@for control flow, Signals) and spends most of its time on why each piece exists and where it tends to go wrong.

The examples target Angular 17 or later. If you are on an older NgModule-based codebase, the concepts map directly; only the wiring differs, and there is a note on migration at the end.

Why teams choose Angular

The practical argument for Angular is not that it can do things React or Vue cannot. It is that many decisions are already made. There is one router, one forms library, one HTTP client and one DI system, and ng generate produces the same file layout in every project. For a team of many developers working on a long-lived internal application, that uniformity reduces the number of architectural debates and makes it easier to move between codebases.

The cost is surface area. You need to be comfortable with TypeScript decorators, dependency injection, RxJS observables and now Signals before you are productive, and upgrades touch the whole framework at once. The Angular CLI’s ng update command automates most version migrations, but on a large app they still deserve a dedicated branch and a test pass. For a small marketing site or a prototype, that overhead is rarely worth it.


Creating a project

npm install -g @angular/cli
ng new my-app
cd my-app
ng serve

ng serve starts a dev server on http://localhost:4200 with live reload. Recent CLI versions generate a standalone app by default: there is no AppModule, and the app is bootstrapped from main.ts with a list of providers.

// src/main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';

bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes),
    provideHttpClient(),
  ],
});

This file is where application-wide services get registered. Forgetting provideHttpClient() here is the single most common error in a new project:

NullInjectorError: No provider for HttpClient!

The message means the injector walked all the way up to the root without finding anyone who knows how to create HttpClient. It is a configuration problem, not a bug in the component that asked for it.


Components

ng generate component user-list   # or: ng g c user-list

A component is a TypeScript class plus a template. Standalone components declare their own template dependencies in imports, so you can see exactly what a template is allowed to use by reading one file.

// src/app/user-list/user-list.component.ts
import { Component } from '@angular/core';

interface User { id: number; name: string; email: string; }

@Component({
  selector: 'app-user-list',
  standalone: true,
  templateUrl: './user-list.component.html',
})
export class UserListComponent {
  users: User[] = [
    { id: 1, name: 'John', email: '[email protected]' },
    { id: 2, name: 'Jane', email: '[email protected]' },
  ];

  deleteUser(id: number): void {
    this.users = this.users.filter(u => u.id !== id);
  }
}
<!-- src/app/user-list/user-list.component.html -->
<h2>Users</h2>
<ul>
  @for (user of users; track user.id) {
    <li>
      {{ user.name }} ({{ user.email }})
      <button (click)="deleteUser(user.id)">Delete</button>
    </li>
  } @empty {
    <li>No users.</li>
  }
</ul>

The track expression in @for is required, and it matters. It tells Angular how to match list items between renders so it can move existing DOM nodes instead of destroying and recreating them. Tracking by a stable ID keeps input focus, animations and component state attached to the right row. Tracking by $index compiles, but after a deletion every following row is treated as a different item. The older *ngFor directive still works; its equivalent is trackBy, which was optional and therefore often missing.

Note also that deleteUser replaces the array rather than calling splice. That habit pays off once you switch components to OnPush change detection (covered below), which only re-checks a component when an input reference changes, an event fires in it, or a signal it reads changes.


Services and dependency injection

Components should render and react to user input. Fetching data, caching and business rules go into services, which Angular creates and hands out through its DI system.

// src/app/services/user.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

export interface User { id: number; name: string; email: string; }

@Injectable({ providedIn: 'root' })
export class UserService {
  private http = inject(HttpClient);
  private apiUrl = 'https://api.example.com/users';

  getUsers(): Observable<User[]> {
    return this.http.get<User[]>(this.apiUrl);
  }
  getUser(id: number): Observable<User> {
    return this.http.get<User>(`${this.apiUrl}/${id}`);
  }
  createUser(user: Omit<User, 'id'>): Observable<User> {
    return this.http.post<User>(this.apiUrl, user);
  }
  updateUser(id: number, user: User): Observable<User> {
    return this.http.put<User>(`${this.apiUrl}/${id}`, user);
  }
  deleteUser(id: number): Observable<void> {
    return this.http.delete<void>(`${this.apiUrl}/${id}`);
  }
}

providedIn: 'root' registers one shared instance for the whole application, and it is tree-shakable: if nothing injects the service, it is dropped from the bundle. Services can also be provided at a route or component level (providers: [UserService]), which creates a separate instance for that subtree. That is useful for state that should reset when you leave a page, and it is also a source of confusing bugs when two parts of the app unknowingly hold different instances of what they assume is a singleton.

inject() and constructor parameters (constructor(private http: HttpClient)) are equivalent. inject() works in field initializers and in functional guards and interceptors, where there is no constructor, so newer code tends to use it everywhere. It only works in an injection context, though: calling it inside a setTimeout or an event handler throws NG0203: inject() must be called from an injection context.

The generic in this.http.get<User[]> is a compile-time promise, not a runtime check. If the API returns something else, TypeScript will not notice. Validate at the boundary if the backend is not under your control.

Using the service in a component

import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { UserService } from '../services/user.service';

@Component({
  selector: 'app-user-list',
  standalone: true,
  template: `
    @if (users(); as list) {
      <ul>
        @for (u of list; track u.id) { <li>{{ u.name }}</li> }
      </ul>
    } @else {
      <p>Loading...</p>
    }
  `,
})
export class UserListComponent {
  private userService = inject(UserService);
  users = toSignal(this.userService.getUsers());
}

HttpClient observables are cold: nothing is sent until something subscribes, and each subscription sends a new request. That is why a template with two async pipes on the same observable makes two identical HTTP calls. toSignal subscribes once, exposes the latest value as a signal (starting as undefined), and unsubscribes when the component is destroyed. If you subscribe manually instead, pipe through takeUntilDestroyed() from @angular/core/rxjs-interop; forgetting to unsubscribe from long-lived streams (a websocket, a store, interval) is the classic Angular memory leak, because the component instance stays referenced by the subscription after it is removed from the page.


Routing

// src/app/app.routes.ts
import { Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';

export const routes: Routes = [
  { path: '', component: HomeComponent },
  {
    path: 'users',
    loadComponent: () =>
      import('./users/users.component').then(m => m.UsersComponent),
  },
  {
    path: 'users/:id',
    loadComponent: () =>
      import('./user-detail/user-detail.component').then(m => m.UserDetailComponent),
  },
  { path: '**', redirectTo: '' },
];

loadComponent makes the route lazy: the users screens are split into a separate JavaScript chunk that is only downloaded when someone navigates there. In an enterprise app with dozens of screens, lazy routes are the main lever for keeping the initial bundle small. Order matters too: the router takes the first match, so the ** wildcard must stay last or it will swallow every route below it.

<!-- app.component.html -->
<nav>
  <a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Home</a>
  <a routerLink="/users" routerLinkActive="active">Users</a>
</nav>
<router-outlet />

exact: true on the home link is needed because / is a prefix of every URL; without it, Home would always look active. The component using these directives must list RouterLink, RouterLinkActive and RouterOutlet in its imports.

Programmatic navigation goes through the Router service:

private router = inject(Router);

goToUser(id: number): void {
  this.router.navigate(['/users', id]);
}

Passing path segments as an array instead of building a string ('/users/' + id) lets the router encode values correctly.


Forms

Angular has two form systems. Template-driven forms (ngModel) keep the model in the template and are quick for a login box. Reactive forms keep the model in the class, which makes validation logic testable without rendering anything and makes dynamic forms (adding rows, conditional fields) manageable.

import { Component, inject } from '@angular/core';
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';

@Component({
  selector: 'app-login',
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="loginForm" (ngSubmit)="onSubmit()">
      <input formControlName="email" type="email" />
      @if (loginForm.controls.email.invalid && loginForm.controls.email.touched) {
        <div>Please enter a valid email.</div>
      }
      <input formControlName="password" type="password" />
      @if (loginForm.controls.password.invalid && loginForm.controls.password.touched) {
        <div>Password must be at least 6 characters.</div>
      }
      <button type="submit" [disabled]="loginForm.invalid">Login</button>
    </form>
  `,
})
export class LoginComponent {
  private fb = inject(FormBuilder);

  loginForm = this.fb.nonNullable.group({
    email: ['', [Validators.required, Validators.email]],
    password: ['', [Validators.required, Validators.minLength(6)]],
  });

  onSubmit(): void {
    if (this.loginForm.valid) {
      const { email, password } = this.loginForm.getRawValue(); // typed as strings
      console.log(email, password);
    }
  }
}

Reactive forms have been strictly typed since Angular 14, so loginForm.controls.email is known to exist and getRawValue() returns { email: string; password: string }. Using fb.nonNullable matters: with a plain fb.group, calling reset() sets controls back to null, so their types become string | null. Also note that .value omits disabled controls while getRawValue() includes them, which surprises people when a disabled field silently vanishes from the submitted payload.

If you see Can't bind to 'formGroup' since it isn't a known property of 'form', the component is missing ReactiveFormsModule in imports. The same error pattern appears for any directive a standalone component forgot to import.


Signals

Signals are Angular’s fine-grained reactive primitive. A signal holds a value; reading it inside a template or a computed registers a dependency; writing it notifies exactly those dependents.

import { Component, signal, computed } from '@angular/core';

@Component({
  selector: 'app-counter',
  standalone: true,
  template: `
    <p>Count: {{ count() }}</p>
    <p>Doubled: {{ doubled() }}</p>
    <button (click)="increment()">Increment</button>
  `,
})
export class CounterComponent {
  count = signal(0);
  doubled = computed(() => this.count() * 2);

  increment(): void {
    this.count.update(value => value + 1);
  }
}

computed is lazy and memoized: it only recalculates when a signal it read has changed, and only when someone reads it. Two rules avoid most signal bugs. First, signals compare with Object.is, so mutating an array in place (items().push(x)) does not notify anyone; use items.update(list => [...list, x]). Second, do not write to signals inside computed; derive values there and put side effects in effect() sparingly.

Signals do not replace RxJS. A signal always has a current value and no notion of time, so it cannot express “wait 300 ms after the last keystroke, cancel the previous request, retry twice”. Observables can. The pattern that works well is RxJS for async streams at the edges (HTTP, websockets, input events) and signals for the state the template reads, connected with toSignal and toObservable.


Change detection and the errors it produces

By default Angular uses zone.js to learn when anything async happened (a click, a timer, an XHR finishing) and then checks every component’s bindings from the root down. That is simple and forgiving, but on large pages it means a lot of work for a small change. changeDetection: ChangeDetectionStrategy.OnPush limits checks for a component to cases where its inputs changed by reference, an event originated inside it, an async pipe emitted, or a signal it reads changed. Combined with immutable updates and signals, OnPush is the standard way to keep big Angular screens responsive.

The error most people meet here is dev-mode only:

NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked.

In development Angular runs a second check after each cycle to verify that bindings are stable. If a lifecycle hook such as ngAfterViewInit changes a value that the parent template already rendered, the second check sees a different value and throws. The fix is to compute the value earlier (in the constructor, ngOnInit, or a computed) rather than wrapping the assignment in setTimeout, which only hides the ordering problem.

When I review Angular code, the two problems I look for first are manual subscribe() calls without any teardown and setTimeout used to silence NG0100. Both “work” in a demo, and both turn into hard-to-trace behavior in a large app: the first as leaked handlers that keep firing after navigation, the second as flicker and order-dependent bugs when the timing changes.


Building and deploying

ng build            # production configuration is the default

The output goes to dist/. With the application builder used by current projects, the static files end up in dist/<project-name>/browser/, not directly in dist/<project-name>/. Copying the wrong folder into a web server is a common reason a first deployment shows the server’s default page or a 404.

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=builder /app/dist/my-app/browser /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
# nginx.conf
server {
  listen 80;
  root /usr/share/nginx/html;
  location / {
    try_files $uri $uri/ /index.html;
  }
}

The try_files fallback is what makes deep links work. Angular’s router handles /users/42 in the browser, but when a user refreshes that page, the request goes to nginx, which has no file at that path. Without the fallback to index.html you get a 404 on every refresh of anything but the home page. Check the dist folder name against your angular.json if your project is not called my-app.


Migrating from NgModules

Existing apps built around NgModule do not have to be rewritten. Standalone components can import NgModules and vice versa, so a migration can go one feature at a time. Angular provides a schematic (ng generate @angular/core:standalone) that converts declarations, removes unnecessary modules and switches bootstrapping in separate steps; running each step on its own branch and checking the test suite between them keeps the diff reviewable. Likewise, ng generate @angular/core:control-flow converts *ngIf/*ngFor templates to the built-in @if/@for syntax.


Frequently Asked Questions

Q. When should I use Signals vs RxJS in Angular?

A. Signals fit synchronous UI state that templates read: counters, selected items, derived values. RxJS fits events over time: HTTP calls, debounced search input, websockets, cancellation. Bridge them with toSignal and toObservable from @angular/core/rxjs-interop.

Q. Why do I get NullInjectorError: No provider for HttpClient?

A. Something injects HttpClient but no provider for it is registered in the injector tree. In a standalone app add provideHttpClient() to the providers of bootstrapApplication; in an NgModule app import HttpClientModule or call provideHttpClient() in the root module.

Q. Is Angular the same as AngularJS?

A. No. AngularJS (1.x) is a different, JavaScript-based framework whose long-term support ended at the end of 2021. Angular (2 and later) was a complete rewrite in TypeScript; the two share a name and some ideas, but code does not carry over.

Q. Should I use template-driven or reactive forms?

A. Template-driven forms with ngModel are quick for small, static forms, but the form model lives in the template and is harder to unit test. Reactive forms define FormGroup and FormControl in the class, give synchronous access to values and validity, and handle dynamic fields (FormArray) and cross-field validators more cleanly, so they are usually the safer default for larger forms.