Organizing Features
As an Angular application grows, the way you arrange files on disk becomes as important as the code itself. With standalone components removing the need for NgModule boilerplate, the natural unit of organization shifts from modules to features: self-contained folders that colocate the components, services, routes, and models that belong together. This approach keeps related code physically close, makes lazy loading trivial, and lets teams work on different features without colliding.
Why organize by feature
In the old NgModule world, projects were often split by type: a components/ folder, a services/ folder, a pipes/ folder. That layout scatters a single feature across the tree — to understand “checkout” you had to jump between four directories. Feature-based organization inverts this: everything checkout-related lives under features/checkout/. When you delete a feature, you delete one folder. When you onboard a developer, they read one folder.
Standalone APIs make this especially clean because routes themselves can carry their providers and lazy-load their components, so a feature folder becomes a deployable, code-split slice of the app with no module wiring.
A recommended folder layout
A common, scalable structure separates app-wide concerns (core, shared) from vertical slices (features):
src/app/
├── core/ # singletons: auth, http interceptors, guards
│ ├── auth.service.ts
│ ├── auth.guard.ts
│ └── api.interceptor.ts
├── shared/ # reusable, presentational, stateless pieces
│ ├── ui/button.component.ts
│ └── pipes/currency-symbol.pipe.ts
├── features/
│ ├── products/
│ │ ├── products.routes.ts
│ │ ├── product-list.component.ts
│ │ ├── product-detail.component.ts
│ │ ├── products.service.ts
│ │ └── product.model.ts
│ └── checkout/
│ ├── checkout.routes.ts
│ ├── checkout.component.ts
│ └── checkout.service.ts
├── app.routes.ts
└── app.config.ts
| Folder | Holds | Lifetime |
|---|---|---|
core | App-wide singletons (auth, interceptors, guards) | Provided once at root |
shared | Stateless reusable UI, pipes, directives | Imported per component |
features/* | Vertical slices with their own routes & services | Lazy-loaded per route |
Tip: Keep
sharedfree of business logic and feature-specific services. If something insharedimports fromfeatures, it doesn’t belong inshared.
Colocating routes with the feature
Each feature owns a *.routes.ts file that exports its Routes array. Components are lazy-loaded with loadComponent, and feature-scoped providers attach directly to the parent route.
// features/products/products.routes.ts
import { Routes } from '@angular/router';
import { ProductsService } from './products.service';
export const PRODUCTS_ROUTES: Routes = [
{
path: '',
// Service is scoped to this route tree, not the whole app
providers: [ProductsService],
children: [
{
path: '',
loadComponent: () =>
import('./product-list.component').then((m) => m.ProductListComponent),
},
{
path: ':id',
loadComponent: () =>
import('./product-detail.component').then((m) => m.ProductDetailComponent),
},
],
},
];
Wiring features into the root router
The root app.routes.ts lazy-loads each feature’s route file with loadChildren. Angular only downloads a feature’s JavaScript when the user navigates to it, so the initial bundle stays small.
// app.routes.ts
import { Routes } from '@angular/router';
export const routes: Routes = [
{ path: '', redirectTo: 'products', pathMatch: 'full' },
{
path: 'products',
loadChildren: () =>
import('./features/products/products.routes').then((m) => m.PRODUCTS_ROUTES),
},
{
path: 'checkout',
canActivate: [() => inject(AuthService).isLoggedIn()],
loadChildren: () =>
import('./features/checkout/checkout.routes').then((m) => m.CHECKOUT_ROUTES),
},
];
When you build, each lazy feature becomes its own chunk:
Output:
Initial chunk files | Names | Raw size
main.js | main | 142.10 kB
polyfills.js | polyfills | 34.58 kB
Lazy chunk files | Names | Raw size
products.routes.js | products | 18.92 kB
checkout.routes.js | checkout | 12.41 kB
A self-contained standalone component
Feature components are standalone and pull in exactly what they need. Using inject(), signals, and the new control flow keeps them compact and modern.
// features/products/product-list.component.ts
import { Component, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { ProductsService } from './products.service';
import { Product } from './product.model';
@Component({
selector: 'app-product-list',
standalone: true,
imports: [RouterLink],
template: `
@if (loading()) {
<p>Loading products…</p>
} @else {
<ul>
@for (product of products(); track product.id) {
<li>
<a [routerLink]="[product.id]">{{ product.name }}</a>
</li>
} @empty {
<li>No products found.</li>
}
</ul>
}
`,
})
export class ProductListComponent {
private service = inject(ProductsService);
protected products = signal<Product[]>([]);
protected loading = signal(true);
constructor() {
this.service.list().subscribe((items) => {
this.products.set(items);
this.loading.set(false);
});
}
}
Scoping services to the right level
Where you provide a service determines how it’s shared. App-wide singletons use providedIn: 'root'; feature-scoped state attaches to the feature’s parent route so it’s created on entry and torn down on exit.
// features/products/products.service.ts
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Product } from './product.model';
@Injectable() // NOT providedIn:'root' — scoped via the route's providers
export class ProductsService {
private http = inject(HttpClient);
list() {
return this.http.get<Product[]>('/api/products');
}
}
Gotcha: A service with
providedIn: 'root'is a global singleton even if its file lives inside a feature folder. To truly scope state to a feature, omitprovidedInand list it in the route’sprovidersarray.
Best Practices
- Organize by feature (vertical slices), not by file type — colocate components, services, routes, and models.
- Give every feature its own
*.routes.tsand lazy-load it from the root vialoadChildrento shrink the initial bundle. - Reserve
core/for app-wide singletons andshared/for stateless, reusable UI — keep both free of feature-specific logic. - Scope feature state with route-level
providersinstead ofprovidedIn: 'root'so it’s created and destroyed with the feature. - Use a barrel-free, shallow import strategy: import components directly in
loadComponentto preserve effective tree-shaking and code splitting. - Keep feature folders dependency-free of one another; share through
shared/orcore/, never feature-to-feature imports.