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.tsFollow 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
| Practice | Why |
|---|---|
| Feature-based folder structure | Easy navigation, clear boundaries |
| Standalone components | Simpler architecture, better tree-shaking |
| Signals for component state | Fine-grained reactivity, less RxJS boilerplate |
OnPush change detection | Fewer change detection cycles |
inject() function | Cleaner than constructor injection |
takeUntilDestroyed | Prevents memory leaks |
| Lazy load routes | Smaller initial bundle |
@defer blocks | Load heavy components on demand |
| Typed API responses | Catch errors at compile time |
| Functional interceptors | Cleaner cross-cutting concerns |
| Smart vs presentational split | Reusable, 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.