Angular: Building Enterprise Web Apps
Put fifteen developers on one frontend codebase for a couple of years and you'll see it happen: three ways of fetching data, two form libraries, auth headers copied into every API call, and a folder structure nobody can explain anymore. Picking libraries one by one works fine for a small team. It gets painful when the team grows and people come and go.
That's the problem Angular is built for. It ships the router, HTTP client, forms, testing setup and CLI together, and it has an opinion about how each of them should be used. In this post I'll go through the parts you'll touch every day: components, data binding, services and dependency injection, routing with guards, reactive forms, and HTTP interceptors. At the end I'll cover when I'd pick Angular and when I wouldn't.
Google uses Angular for many of its own internal and public apps, and it shows up a lot in banking, insurance and internal enterprise tools. The reason is mostly boring: TypeScript by default, DI everywhere, and one standard project layout make a codebase easier to hand over to the next developer.
Angular in Short
First, a naming thing that still confuses people: AngularJS (2010) and Angular (2016 onward) are different frameworks. Angular was a full rewrite in TypeScript, and none of the AngularJS code carries over. It uses components, dependency injection, RxJS for async work, and (historically) NgModules to organize code.
Because it's opinionated, most things have one expected way of being done:
- TypeScript-first: Angular is written in TypeScript and expects you to use it. It's not an optional add-on.
- Dependency Injection: A hierarchical DI system lets you share services across components cleanly
- RxJS Observables: The HTTP client, router, and form value changes all give you Observables
- Angular CLI: Generates components, services, modules, and runs builds/tests with a single command
- Zone.js Change Detection: Angular notices most state changes (clicks, timers, HTTP responses) and updates the view on its own. Newer versions also offer signals and a zoneless mode if you want finer control.
Angular vs React vs Vue
| Feature | Angular | React | Vue |
|---|---|---|---|
| Type | Full Framework | UI Library | Progressive Framework |
| Language | TypeScript (required) | JS / TypeScript | JS / TypeScript |
| Learning Curve | Steep | Moderate | Gentle |
| Bundle Size | Largest baseline | Small core (grows with add-ons) | Small |
| Built-in Router | Yes | No (React Router) | Yes (Vue Router) |
| HTTP Client | Yes (built-in) | No (fetch/axios) | No (axios) |
| Best For | Enterprise apps | Flexible UIs | Rapid prototyping |
Angular Application Architecture
Setting Up Angular
You'll do almost everything through the CLI: scaffolding, building, testing, and deploying. If Node.js is already installed, you can have a project running in a few minutes.
Install Node.js (v18+ recommended)
Angular needs Node.js. Grab it from nodejs.org, or use nvm so you can switch versions per project. Each Angular major supports a specific range of Node versions, so check the compatibility table before upgrading either one.
Install Angular CLI globally
The Angular CLI (ng) is what you'll use to create the project and to generate, serve, build and test it afterwards.
# Install Angular CLI globally
npm install -g @angular/cli
# Verify installation
ng version
# Create a new Angular project
ng new my-enterprise-app
# During setup you'll be asked:
# - Would you like to add Angular routing? Yes
# - Which stylesheet format? CSS (or SCSS for larger projects)
# Navigate to project and start dev server
cd my-enterprise-app
ng serve --open
Understand the Project Structure
Your application code lives in src/app/. One heads-up: since Angular 17, ng new creates a standalone app by default, so you'll get app.config.ts and app.routes.ts instead of app.module.ts. The tree below shows the classic NgModule layout, which you'll still find in most existing enterprise codebases.
my-enterprise-app/
├── src/
│ ├── app/
│ │ ├── app.component.ts # Root component class
│ │ ├── app.component.html # Root component template
│ │ ├── app.component.css # Root component styles
│ │ ├── app.module.ts # Root module (declarations, imports)
│ │ └── app-routing.module.ts # Route definitions
│ ├── assets/ # Static files (images, fonts)
│ ├── environments/ # Environment configs (dev/prod)
│ │ ├── environment.ts
│ │ └── environment.prod.ts
│ ├── index.html # Single HTML file
│ └── main.ts # Bootstrap entry point
├── angular.json # CLI config (build, test, serve)
├── tsconfig.json # TypeScript config
└── package.json
Core Concepts: Components
Every screen in an Angular app is a tree of components. Each one owns a piece of the UI and comes in three parts: a TypeScript class for the logic, an HTML template for the view, and CSS for the styles.
Component Anatomy
A plain class becomes a component once you add the @Component decorator. The metadata in it (selector, template, styles) tells the compiler how to wire things up at build time.
import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
interface User {
id: number;
name: string;
email: string;
role: string;
}
@Component({
selector: 'app-user-card',
templateUrl: './user-card.component.html',
styleUrls: ['./user-card.component.css']
})
export class UserCardComponent implements OnInit {
// @Input() receives data from parent component
@Input() user!: User;
@Input() showActions = true;
// @Output() emits events to parent component
@Output() userSelected = new EventEmitter<User>();
@Output() userDeleted = new EventEmitter<number>();
isExpanded = false;
ngOnInit(): void {
// Lifecycle hook: runs once after component initializes
console.log('UserCard initialized for:', this.user.name);
}
selectUser(): void {
this.userSelected.emit(this.user);
}
deleteUser(): void {
if (confirm(`Delete ${this.user.name}?`)) {
this.userDeleted.emit(this.user.id);
}
}
toggleExpand(): void {
this.isExpanded = !this.isExpanded;
}
}
<div class="user-card" [class.expanded]="isExpanded">
<!-- Interpolation: {{ }} outputs data as text -->
<h3>{{ user.name }}</h3>
<!-- Property binding: [property]="expression" -->
<span [class]="'badge ' + user.role.toLowerCase()">
{{ user.role }}
</span>
<p>{{ user.email }}</p>
<!-- Structural directive: *ngIf for conditional rendering -->
<div *ngIf="isExpanded" class="details">
<p>ID: {{ user.id }}</p>
</div>
<!-- Event binding: (event)="handler()" -->
<div *ngIf="showActions" class="actions">
<button (click)="toggleExpand()">Details</button>
<button (click)="selectUser()">Select</button>
<button (click)="deleteUser()" class="danger">Delete</button>
</div>
</div>
Generating Components with CLI
I don't create component files by hand. The CLI writes all four files (TypeScript, HTML, CSS, spec) and, in a module-based project, registers the component in the nearest module so you don't forget:
# Generate a component (creates 4 files + updates module)
ng generate component user-card
# Shorthand: ng g c user-card
# Generate inside a feature folder
ng g c features/users/user-list
# Generate with inline template and styles (single file)
ng g c shared/spinner --inline-template --inline-style
# Generate a standalone component (Angular 14+)
ng g c dashboard --standalone
Data Binding & Templates
There are four kinds of binding in Angular templates, and the brackets tell you which way the data flows. Square brackets push data into the DOM, parentheses listen for events coming out, and [( )] does both. Once that clicks, templates get much easier to read.
<!-- 1. Interpolation: Component → Template (read-only display) -->
<h1>Welcome, {{ currentUser.name }}!</h1>
<p>{{ getGreeting() }}</p>
<p>Total: {{ items.length }} items</p>
<!-- 2. Property Binding: Component → DOM element (one-way) -->
<img [src]="user.avatarUrl" [alt]="user.name">
<button [disabled]="isLoading">Submit</button>
<div [class.active]="isActive" [style.color]="themeColor">...</div>
<!-- 3. Event Binding: DOM → Component (user interactions) -->
<button (click)="saveUser()">Save</button>
<input (keyup.enter)="search()">
<form (ngSubmit)="onSubmit($event)">...</form>
<!-- 4. Two-way Binding: Both directions (forms) -->
<!-- Requires FormsModule in AppModule -->
<input [(ngModel)]="searchTerm" placeholder="Search...">
<p>You typed: {{ searchTerm }}</p>
<!-- Structural Directives -->
<ul>
<li *ngFor="let item of items; let i = index; trackBy: trackById">
{{ i + 1 }}. {{ item.name }}
</li>
</ul>
<div [ngSwitch]="userRole">
<p *ngSwitchCase="'admin'">Admin Panel</p>
<p *ngSwitchCase="'editor'">Editor Tools</p>
<p *ngSwitchDefault>Viewer Mode</p>
</div>
Use trackBy with *ngFor on any list that gets refreshed from an API. Without it, Angular tracks rows by object reference, so when you replace the array with freshly fetched objects it destroys and rebuilds every row, even if the data is the same. With trackBy: trackById it keeps the existing DOM nodes and only touches rows whose ID changed. On a table with hundreds of rows you'll feel the difference. (The newer @for syntax makes track mandatory for this reason.)
Services & Dependency Injection
Business logic, shared state and API calls go into services, not components. You never call new UserService() yourself. Angular's dependency injection (DI) creates the instance and hands it to whoever asks for it in their constructor.
So a component just declares what it needs, and Angular works out where it comes from. In tests you swap in a mock service through the same mechanism, which is a big part of why Angular code tends to be easy to unit test.
NullInjectorError: No provider for HttpClient! means nobody registered the HTTP client. In a module-based app, add HttpClientModule to your AppModule imports. In a standalone app, add provideHttpClient() to the providers in app.config.ts. You'll see the same error in unit tests if the TestBed doesn't provide it, so use HttpClientTestingModule (or provideHttpClientTesting()) there.
import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable, BehaviorSubject, throwError } from 'rxjs';
import { map, catchError, tap } from 'rxjs/operators';
export interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'editor' | 'viewer';
}
// providedIn: 'root' = singleton available app-wide (most common)
@Injectable({
providedIn: 'root'
})
export class UserService {
private apiUrl = 'https://api.example.com/users';
// BehaviorSubject holds current state and emits to subscribers
private usersSubject = new BehaviorSubject<User[]>([]);
users$ = this.usersSubject.asObservable(); // expose as Observable
constructor(private http: HttpClient) {}
getUsers(page = 1, limit = 20): Observable<User[]> {
const params = new HttpParams()
.set('page', page.toString())
.set('limit', limit.toString());
return this.http.get<User[]>(this.apiUrl, { params }).pipe(
tap(users => this.usersSubject.next(users)), // update state
catchError(error => {
console.error('Failed to fetch users:', error);
return throwError(() => new Error('Unable to load users'));
})
);
}
getUserById(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).pipe(
tap(newUser => {
const currentUsers = this.usersSubject.getValue();
this.usersSubject.next([...currentUsers, newUser]);
})
);
}
updateUser(id: number, changes: Partial<User>): Observable<User> {
return this.http.patch<User>(`${this.apiUrl}/${id}`, changes);
}
deleteUser(id: number): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`).pipe(
tap(() => {
const filtered = this.usersSubject.getValue().filter(u => u.id !== id);
this.usersSubject.next(filtered);
})
);
}
}
import { Component, OnInit, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { UserService, User } from '../services/user.service';
@Component({
selector: 'app-user-list',
templateUrl: './user-list.component.html'
})
export class UserListComponent implements OnInit, OnDestroy {
users: User[] = [];
isLoading = false;
error: string | null = null;
// Used to unsubscribe and prevent memory leaks
private destroy$ = new Subject<void>();
// Angular injects UserService automatically
constructor(private userService: UserService) {}
ngOnInit(): void {
this.isLoading = true;
this.userService.getUsers().pipe(
takeUntil(this.destroy$) // auto-unsubscribe when component destroys
).subscribe({
next: users => {
this.users = users;
this.isLoading = false;
},
error: err => {
this.error = err.message;
this.isLoading = false;
}
});
}
ngOnDestroy(): void {
// Signal all takeUntil operators to complete
this.destroy$.next();
this.destroy$.complete();
}
onUserDeleted(userId: number): void {
this.userService.deleteUser(userId).subscribe();
}
}
Angular Router
The router covers client-side navigation, lazy loading, and guards that block routes. You describe the routes as a plain array, and lazy loading is a one-liner per route. Lazy loading matters more than people expect: without it, the admin panel's code ships to every user who only ever opens the dashboard.
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from './guards/auth.guard';
import { AdminGuard } from './guards/admin.guard';
const routes: Routes = [
{
path: '',
redirectTo: '/dashboard',
pathMatch: 'full'
},
{
path: 'dashboard',
// Lazy load: DashboardModule only downloads when user navigates here
loadChildren: () =>
import('./features/dashboard/dashboard.module').then(m => m.DashboardModule),
canActivate: [AuthGuard] // Guard: must be logged in
},
{
path: 'users',
loadChildren: () =>
import('./features/users/users.module').then(m => m.UsersModule),
canActivate: [AuthGuard]
},
{
path: 'admin',
loadChildren: () =>
import('./features/admin/admin.module').then(m => m.AdminModule),
canActivate: [AuthGuard, AdminGuard] // Must be logged in AND admin
},
{
path: 'users/:id', // Route parameter
loadComponent: () =>
import('./features/users/user-detail/user-detail.component')
.then(c => c.UserDetailComponent), // Standalone component (Angular 14+)
canActivate: [AuthGuard]
},
{
path: 'login',
loadComponent: () =>
import('./auth/login/login.component').then(c => c.LoginComponent)
},
{
path: '**', // Wildcard: catches 404s
loadComponent: () =>
import('./shared/not-found/not-found.component').then(c => c.NotFoundComponent)
}
];
@NgModule({
imports: [RouterModule.forRoot(routes, {
scrollPositionRestoration: 'enabled', // scroll to top on navigation
anchorScrolling: 'enabled'
})],
exports: [RouterModule]
})
export class AppRoutingModule {}
Creating an Auth Guard
import { Injectable } from '@angular/core';
import { CanActivate, Router, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { AuthService } from '../services/auth.service';
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
constructor(
private authService: AuthService,
private router: Router
) {}
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): boolean {
if (this.authService.isAuthenticated()) {
return true;
}
// Store attempted URL so we can redirect back after login
this.router.navigate(['/login'], {
queryParams: { returnUrl: state.url }
});
return false;
}
}
Reactive Forms
Angular has two ways to build forms: template-driven (simple, uses ngModel) and reactive (defined in TypeScript). For anything beyond a login box I use reactive forms. They're easier to test, cross-field validation like "passwords must match" is straightforward, and you can read or change form state from code without poking at the template.
import { Component, OnInit } from '@angular/core';
import {
FormBuilder,
FormGroup,
Validators,
AbstractControl,
ValidationErrors
} from '@angular/forms';
// Custom validator: password and confirm must match
function passwordMatchValidator(control: AbstractControl): ValidationErrors | null {
const password = control.get('password');
const confirm = control.get('confirmPassword');
if (password && confirm && password.value !== confirm.value) {
return { passwordMismatch: true };
}
return null;
}
@Component({
selector: 'app-user-form',
templateUrl: './user-form.component.html'
})
export class UserFormComponent implements OnInit {
userForm!: FormGroup;
isSubmitting = false;
constructor(private fb: FormBuilder, private userService: UserService) {}
ngOnInit(): void {
this.userForm = this.fb.group({
name: ['', [Validators.required, Validators.minLength(2), Validators.maxLength(50)]],
email: ['', [Validators.required, Validators.email]],
role: ['viewer', Validators.required],
passwordGroup: this.fb.group(
{
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['', Validators.required]
},
{ validators: passwordMatchValidator }
)
});
}
// Convenience getter for clean template access
get name() { return this.userForm.get('name'); }
get email() { return this.userForm.get('email'); }
get passwordGroup() { return this.userForm.get('passwordGroup'); }
onSubmit(): void {
if (this.userForm.invalid) {
this.userForm.markAllAsTouched(); // show all validation errors
return;
}
this.isSubmitting = true;
const { name, email, role, passwordGroup } = this.userForm.value;
this.userService.createUser({ name, email, role }).subscribe({
next: () => {
this.userForm.reset({ role: 'viewer' });
this.isSubmitting = false;
},
error: () => {
this.isSubmitting = false;
}
});
}
}
<form [formGroup]="userForm" (ngSubmit)="onSubmit()">
<div class="field">
<label>Name</label>
<input formControlName="name" placeholder="Full name">
<!-- Show error only after user has touched the field -->
<div *ngIf="name?.invalid && name?.touched" class="error">
<span *ngIf="name?.errors?.['required']">Name is required</span>
<span *ngIf="name?.errors?.['minlength']">At least 2 characters</span>
</div>
</div>
<div class="field">
<label>Email</label>
<input formControlName="email" type="email" placeholder="user@example.com">
<div *ngIf="email?.invalid && email?.touched" class="error">
<span *ngIf="email?.errors?.['email']">Enter a valid email address</span>
</div>
</div>
<div class="field">
<label>Role</label>
<select formControlName="role">
<option value="viewer">Viewer</option>
<option value="editor">Editor</option>
<option value="admin">Admin</option>
</select>
</div>
<div formGroupName="passwordGroup">
<div *ngIf="passwordGroup?.errors?.['passwordMismatch']" class="error">
Passwords do not match
</div>
</div>
<button type="submit" [disabled]="isSubmitting">
{{ isSubmitting ? 'Creating...' : 'Create User' }}
</button>
</form>
HTTP Client & Interceptors
The HTTP client returns Observables, which is handy for cancelling and retrying requests. The part I'd learn first, though, is interceptors: middleware that every request and response passes through. That's where auth tokens, global error handling, and API logging belong.
import { Injectable } from '@angular/core';
import {
HttpInterceptor, HttpRequest, HttpHandler,
HttpEvent, HttpErrorResponse
} from '@angular/common/http';
import { Observable, throwError } from 'rxjs';
import { catchError, switchMap } from 'rxjs/operators';
import { AuthService } from '../services/auth.service';
import { Router } from '@angular/router';
@Injectable()
export class AuthInterceptor implements HttpInterceptor {
constructor(private authService: AuthService, private router: Router) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
const token = this.authService.getToken();
// Clone request and add auth header (requests are immutable)
const authReq = token
? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
: req;
return next.handle(authReq).pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === 401) {
// Token expired, so attempt a refresh
return this.authService.refreshToken().pipe(
switchMap(newToken => {
const retried = req.clone({
setHeaders: { Authorization: `Bearer ${newToken}` }
});
return next.handle(retried);
}),
catchError(() => {
// Refresh also failed: logout and redirect
this.authService.logout();
this.router.navigate(['/login']);
return throwError(() => error);
})
);
}
if (error.status === 403) {
this.router.navigate(['/unauthorized']);
}
return throwError(() => error);
})
);
}
}
// Register in AppModule providers:
// { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }
If every service adds its own auth header and its own 401 handling, you'll end up with dozens of copies that slowly drift apart. Put that logic in an interceptor and change it in one place. One gotcha with the refresh logic above: if five requests fail with 401 at the same moment, you'll fire five refresh calls. In production you usually want to share a single in-flight refresh (for example with a BehaviorSubject or shareReplay) and skip the interceptor for the refresh endpoint itself, or a failing refresh can loop.
When I'd Use Angular (and When I Wouldn't)
The learning curve is real. DI, RxJS, decorators and the build setup all land on a new developer in the first week. On a large, long-lived app with a big team, that cost pays for itself, because everyone writes code that looks the same and new people already know where things go.
Where it fits
- Large internal tools and dashboards with lots of forms, tables and role-based routes. Reactive forms, guards and interceptors cover most of that out of the box.
- Big or rotating teams where a shared structure matters more than picking the trendiest library.
- Teams already strong in TypeScript, or coming from backend frameworks with DI (Spring, .NET, Laravel's container). The concepts will feel familiar.
Where I'd pick something else
- Small sites, landing pages and blogs. The baseline bundle and setup are overkill. Plain HTML, Astro, or a light Vue/React setup will ship faster.
- Small teams that need to prototype quickly and may throw the code away. Angular's structure slows the first few weeks down.
- Teams with no RxJS experience and no time to learn it. Misused Observables cause subtle bugs: forgotten unsubscribes, nested
subscribe()calls, and the wrong flattening operator. Signals help here, but you'll still meet RxJS in the HTTP client and router.
If you do go with Angular, two things are worth checking before you go to production: make sure every feature route is lazy loaded (look at the initial chunk size in the ng build output), and grep for subscribe( in components to find subscriptions that never get cleaned up.