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.
| Layer | Contains | May import from | Example |
|---|---|---|---|
core/ | App-wide singletons, provided once | shared/ | AuthService, authInterceptor |
shared/ | Generic, reusable, business-agnostic | Other shared/ only | ButtonComponent, TruncatePipe |
features/<x>/ | One vertical product slice | core/, shared/, same feature | CartPageComponent, 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:
PascalCasewith a type suffix—CartPageComponent,CartService,authGuard. - File names:
kebab-casewith 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 viaprefixinangular.json. - Routes files:
<feature>.routes.tsexporting a single uppercaseFEATURE_ROUTESconstant. - 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) withinject()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
loadChildrenand a dedicated<feature>.routes.tsfile. - Colocate components with their templates, styles, tests, and private children.
- Keep
core/for once-only singletons andshared/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 generateenforce it. - Prefer standalone components, signals, and functional guards/interceptors with
inject()over legacy class APIs.