Skip to content
Angular best practices 4 min read

Project Structure Best Practices

A fresh ng new app fits comfortably in a flat src/app folder, but that arrangement buckles the moment the project grows past a handful of routes. With standalone components now the default in Angular 17/18/19, the old NgModule-per-feature layout is gone, and structure is driven by folders, route boundaries, and disciplined imports instead. This page lays out an opinionated, feature-first structure where related code lives together, shared code is deliberate, and any file’s purpose is obvious from its location.

Organize by feature, not by file type

The instinct of grouping files by kind—every component in components/, every service in services/, every guard in guards/—reads well in a tutorial and falls apart at scale. A single feature ends up smeared across four folders, and unrelated code piles up in each.

Instead, group by feature. A feature is a vertical slice of the product—checkout, authentication, the dashboard—and everything that slice needs lives in one folder, lazily loaded through its own routes file.

src/app/
  core/                 # app-wide singletons, loaded once
    interceptors/
    services/
  shared/               # reusable, business-agnostic building blocks
    ui/
    pipes/
  features/
    cart/
      cart.routes.ts
      cart-page.component.ts
      cart-line-item.component.ts
      data/
        cart.service.ts
    auth/
      auth.routes.ts
      login.component.ts
      data/
        auth.service.ts
  app.config.ts
  app.routes.ts
  app.component.ts

When a requirement changes, the edit usually touches one folder rather than five scattered files. Each feature is loaded with loadChildren, so it ships as its own bundle.

// app.routes.ts
import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: 'cart',
    loadChildren: () => import('./features/cart/cart.routes').then(m => m.CART_ROUTES),
  },
  {
    path: 'auth',
    loadChildren: () => import('./features/auth/auth.routes').then(m => m.AUTH_ROUTES),
  },
  { path: '', pathMatch: 'full', redirectTo: 'cart' },
];

Colocate everything a unit owns

Colocation is the single most useful rule: keep a thing next to the code that uses it. A standalone component’s template, styles, tests, and private child components belong beside it, not in a parallel tree mirroring src/.

features/cart/
  cart-line-item.component.ts
  cart-line-item.component.html
  cart-line-item.component.css
  cart-line-item.component.spec.ts

When you open cart-line-item.component.ts, its tests and styles are right there, and moving or deleting the component moves or deletes all of it at once.

import { Component, input, output } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { CartItem } from './data/cart.service';

@Component({
  selector: 'app-cart-line-item',
  standalone: true,
  imports: [CurrencyPipe],
  templateUrl: './cart-line-item.component.html',
})
export class CartLineItemComponent {
  item = input.required<CartItem>();
  remove = output<string>();
}
<li class="row">
  <span>{{ item().name }}</span>
  <span>{{ item().price * item().qty | currency }}</span>
  <button type="button" (click)="remove.emit(item().id)">Remove</button>
</li>

A service, guard, or component used by exactly one feature should live inside that feature. Promote it to shared/ only when a second feature genuinely needs it—not in anticipation.

Draw a clear line between core, shared, and feature code

Three layers keep dependencies sane. core/ holds app-wide singletons provided once at bootstrap (HTTP interceptors, auth state, configuration). shared/ holds business-agnostic building blocks—a ButtonComponent, a truncate pipe, a useDebounce utility. Features may import from both, but the dependency must never flow back the other way.

LayerContainsMay import fromExample
core/App-wide singletons, provided onceshared/AuthService, authInterceptor
shared/Generic, reusable, business-agnosticOther shared/ onlyButtonComponent, TruncatePipe
features/<x>/One vertical product slicecore/, shared/, same featureCartPageComponent, CartService

Keeping the direction one-way (features → core/shared) prevents circular imports and keeps features independently deletable. Wire core providers once in app.config.ts with modern functional APIs:

// app.config.ts
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './core/interceptors/auth.interceptor';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideRouter(routes),
    provideHttpClient(withInterceptors([authInterceptor])),
  ],
};

Adopt consistent naming conventions

Conventions only help if they are predictable, so pick one rule per category and apply it everywhere. The Angular CLI already enforces most of these when you scaffold with ng generate.

  • Class names: PascalCase with a type suffix—CartPageComponent, CartService, authGuard.
  • File names: kebab-case with a type suffix—cart-page.component.ts, cart.service.ts, auth.guard.ts.
  • Selectors: a consistent prefix per area—app-cart-page, ui-button—configured via prefix in angular.json.
  • Routes files: <feature>.routes.ts exporting a single uppercase FEATURE_ROUTES constant.
  • One primary export per file, named—no default exports.
// auth.guard.ts — a functional guard, the modern replacement for class guards
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './services/auth.service';

export const authGuard: CanActivateFn = () => {
  const auth = inject(AuthService);
  const router = inject(Router);
  return auth.isLoggedIn() ? true : router.createUrlTree(['/auth/login']);
};

Prefer functional guards and interceptors (CanActivateFn, HttpInterceptorFn) with inject() over the legacy class-based versions. They are tree-shakable, easier to compose, and the official direction for modern Angular.

Best Practices

  • Group source by feature so a change touches one folder, not a file-type sprawl.
  • Lazy-load each feature with loadChildren and a dedicated <feature>.routes.ts file.
  • Colocate components with their templates, styles, tests, and private children.
  • Keep core/ for once-only singletons and shared/ strictly business-agnostic, with a one-way dependency direction.
  • Promote code to shared/ only on the second real use, never preemptively.
  • Apply one consistent naming rule per category and let ng generate enforce it.
  • Prefer standalone components, signals, and functional guards/interceptors with inject() over legacy class APIs.
Last updated June 14, 2026
Was this helpful?