Skip to main content

Writing Clean and Scalable Angular Applications

February 18, 2026

</>

Building scalable Angular applications requires more than just knowing the framework. It requires discipline, patterns, and conventions that keep your codebase maintainable as it grows. Here are the best practices every Angular developer should follow.

Project Structure

Use a Feature-Based Folder Structure

Organize by feature, not by type. This keeps related files together and makes navigation easier.

src/
├── app/
   ├── core/              # Singleton services, guards, interceptors
      ├── auth.service.ts
      ├── auth.guard.ts
      └── http-error.interceptor.ts
   ├── shared/            # Reusable components, pipes, directives
      ├── components/
      ├── pipes/
      └── directives/
   ├── features/
      ├── dashboard/
         ├── dashboard.component.ts
         ├── dashboard.routes.ts
         └── widgets/
      ├── users/
         ├── user-list.component.ts
         ├── user-detail.component.ts
         ├── user.service.ts
         └── users.routes.ts
      └── products/
   ├── app.component.ts
   ├── app.config.ts
   └── app.routes.ts

Follow the Single Responsibility Principle

Each file should do one thing. Don't put multiple components, services, or interfaces in a single file.

// Bad - multiple things in one file
@Component({ ... })
export class UserComponent { }

@Pipe({ name: 'userFilter' })
export class UserFilterPipe { }

export interface User { }
// Good - separate files
// user.component.ts
// user-filter.pipe.ts
// user.model.ts

Components

Use Standalone Components

Since Angular 17+, standalone components are the default. They simplify the module system and improve tree-shaking.

@Component({
  selector: 'app-header',
  standalone: true,
  imports: [RouterLink, NgOptimizedImage],
  template: `
    <nav>
      <a routerLin="/">Home</a>
      <a routerLin="/about">About</a>
    </nav>
  `,
})
export class HeaderComponent {}

Use Signals for Reactive State

Angular signals provide fine-grained reactivity without RxJS complexity for component state:

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

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

  increment() {
    this.count.update((c) => c + 1);
  }
}

Smart vs Presentational Components

Separate logic from presentation:

// Smart component - handles data and logic
@Component({
  selector: 'app-user-list-page',
  standalone: true,
  imports: [UserListComponent],
  template: `<app-user-list [users="users()" (delete="onDelete($event)" />`,
})
export class UserListPageComponent {
  private userService = inject(UserService);
  users = toSignal(this.userService.getAll());

  onDelete(id: string) {
    this.userService.delete(id);
  }
}

// Presentational component - only renders UI
@Component({
  selector: 'app-user-list',
  standalone: true,
  template: `
    @for (user of users; track user.id) {
      <div>
        {{ user.name }}
        <button (click="delete.emit(user.id)">Delete</button>
      </div>
    }
  `,
})
export class UserListComponent {
  @Input({ required: true }) users!: User[];
  @Output() delete = new EventEmitter<string>();
}

Use OnPush Change Detection

Reduce unnecessary change detection cycles:

@Component({
  selector: 'app-user-card',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<div>{{ user.name }}</div>`,
})
export class UserCardComponent {
  @Input({ required: true }) user!: User;
}

Services and Dependency Injection

Use inject() Instead of Constructor Injection

The inject() function is cleaner and works with standalone components:

// Old way
constructor(
  private userService: UserService,
  private router: Router,
) {}

// Better way
private userService = inject(UserService);
private router = inject(Router);

Provide Services at the Right Level

// App-wide singleton - use providedIn: 'root'
@Injectable({ providedIn: 'root' })
export class AuthService {}

// Feature-scoped - provide in route config
export const userRoutes: Routes = [
  {
    path: '',
    providers: [UserService],
    children: [
      { path: '', component: UserListComponent },
      { path: ':id', component: UserDetailComponent },
    ],
  },
];

RxJS Best Practices

Always Unsubscribe

Use takeUntilDestroyed to auto-unsubscribe when the component is destroyed:

import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

export class SearchComponent {
  private destroyRef = inject(DestroyRef);

  ngOnInit() {
    this.searchControl.valueChanges
      .pipe(
        debounceTime(300),
        distinctUntilChanged(),
        takeUntilDestroyed(this.destroyRef),
      )
      .subscribe((term) => this.search(term));
  }
}

Or use the async pipe in templates — it handles unsubscription automatically:

@Component({
  template: `
    @if (users$ | async; as users) {
      @for (user of users; track user.id) {
        <app-user-card [user="user" />
      }
    }
  `,
})
export class UserListComponent {
  users$ = inject(UserService).getAll();
}

Avoid Nested Subscriptions

// Bad - nested subscriptions
this.route.params.subscribe((params) => {
  this.userService.getUser(params['id']).subscribe((user) => {
    this.user = user;
  });
});

// Good - use switchMap
this.user$ = this.route.params.pipe(
  switchMap((params) => this.userService.getUser(params['id'])),
);

Performance

Lazy Load Routes

Only load feature modules when the user navigates to them:

export const routes: Routes = [
  {
    path: 'dashboard',
    loadComponent: () =>
      import('./features/dashboard/dashboard.component').then(
        (m) => m.DashboardComponent,
      ),
  },
  {
    path: 'admin',
    loadChildren: () =>
      import('./features/admin/admin.routes').then((m) => m.adminRoutes),
  },
];

Use @defer for Heavy Components

Defer loading of below-the-fold or conditionally visible components:

@defer (on viewport) {
  <app-heavy-chart [data="chartData" />
} @placeholder {
  <div class="skeleton-chart"></div>
} @loading (minimum 500ms) {
  <app-spinner />
}

Optimize Images with NgOptimizedImage

import { NgOptimizedImage } from '@angular/common';

@Component({
  standalone: true,
  imports: [NgOptimizedImage],
  template: `
    <img ngSr="/assets/hero.jpg" widt="800" heigh="400" priority />
    <img ngSr="/assets/thumb.jpg" widt="200" heigh="200" loadin="lazy" />
  `,
})
export class HeroComponent {}

Use trackBy with @for

Always provide a track expression to avoid unnecessary DOM re-creation:

<!-- Angular 17+ control flow -->
@for (item of items; track item.id) {
  <app-item [data="item" />
}

Forms

Use Reactive Forms for Complex Forms

@Component({
  standalone: true,
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup="form" (ngSubmit="onSubmit()">
      <input formControlNam="name" />
      @if (form.get('name')?.hasError('required') && form.get('name')?.touched) {
        <span clas="error">Name is required</span>
      }
      <input formControlNam="email" />
      <button [disabled="form.invalid">Submit</button>
    </form>
  `,
})
export class UserFormComponent {
  private fb = inject(FormBuilder);

  form = this.fb.group({
    name: ['', [Validators.required, Validators.minLength(2)]],
    email: ['', [Validators.required, Validators.email]],
  });

  onSubmit() {
    if (this.form.valid) {
      console.log(this.form.getRawValue());
    }
  }
}

Create Reusable Form Validators

// validators/password.validator.ts
export function strongPassword(): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const value = control.value;
    if (!value) return null;

    const hasUpperCase = /[A-Z]/.test(value);
    const hasLowerCase = /[a-z]/.test(value);
    const hasNumber = /\d/.test(value);
    const hasMinLength = value.length >= 8;

    const valid = hasUpperCase && hasLowerCase && hasNumber && hasMinLength;
    return valid ? null : { strongPassword: true };
  };
}

HTTP and API Calls

Use Interceptors for Cross-Cutting Concerns

// Functional interceptor (Angular 17+)
export const authInterceptor: HttpInterceptorFn = (req, next) => {
  const authService = inject(AuthService);
  const token = authService.getToken();

  if (token) {
    req = req.clone({
      setHeaders: { Authorization: `Bearer ${token}` },
    });
  }

  return next(req);
};

// Register in app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(withInterceptors([authInterceptor])),
  ],
};

Type Your API Responses

interface ApiResponse<T> {
  data: T;
  message: string;
  status: number;
}

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

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

  getAll(): Observable<User[]> {
    return this.http
      .get<ApiResponse<User[]>>(this.apiUrl)
      .pipe(map((res) => res.data));
  }

  getById(id: string): Observable<User> {
    return this.http
      .get<ApiResponse<User>>(`${this.apiUrl}/${id}`)
      .pipe(map((res) => res.data));
  }
}

Error Handling

Use a Global Error Handler

@Injectable()
export class GlobalErrorHandler implements ErrorHandler {
  private logger = inject(LoggingService);

  handleError(error: any): void {
    this.logger.logError(error);
    console.error('Unexpected error:', error);
  }
}

// Register in app.config.ts
export const appConfig: ApplicationConfig = {
  providers: [
    { provide: ErrorHandler, useClass: GlobalErrorHandler },
  ],
};

Handle HTTP Errors with an Interceptor

export const errorInterceptor: HttpInterceptorFn = (req, next) => {
  const router = inject(Router);

  return next(req).pipe(
    catchError((error: HttpErrorResponse) => {
      if (error.status === 401) {
        router.navigate(['/login']);
      } else if (error.status === 403) {
        router.navigate(['/forbidden']);
      }
      return throwError(() => error);
    }),
  );
};

Testing

Write Focused Unit Tests

describe('UserService', () => {
  let service: UserService;
  let httpMock: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [provideHttpClient(), provideHttpClientTesting()],
    });
    service = TestBed.inject(UserService);
    httpMock = TestBed.inject(HttpTestingController);
  });

  it('should fetch users', () => {
    const mockUsers: User[] = [{ id: '1', name: 'John', email: 'john@test.com' }];

    service.getAll().subscribe((users) => {
      expect(users).toEqual(mockUsers);
    });

    const req = httpMock.expectOne('/api/users');
    expect(req.request.method).toBe('GET');
    req.flush({ data: mockUsers, message: 'OK', status: 200 });
  });
});

Quick Reference Checklist

PracticeWhy
Feature-based folder structureEasy navigation, clear boundaries
Standalone componentsSimpler architecture, better tree-shaking
Signals for component stateFine-grained reactivity, less RxJS boilerplate
OnPush change detectionFewer change detection cycles
inject() functionCleaner than constructor injection
takeUntilDestroyedPrevents memory leaks
Lazy load routesSmaller initial bundle
@defer blocksLoad heavy components on demand
Typed API responsesCatch errors at compile time
Functional interceptorsCleaner cross-cutting concerns
Smart vs presentational splitReusable, testable components

Summary

Following these best practices will help you build Angular applications that are:

  • Maintainable — clear structure and separation of concerns
  • Performant — lazy loading, OnPush, deferred views
  • Testable — small, focused components and services
  • Scalable — feature-based architecture that grows with your team

Start with the basics — standalone components, signals, and a clean folder structure — then adopt more patterns as your application grows.

Recommended Posts