Skip to content
Angular ng modules 4 min read

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 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
FolderHoldsLifetime
coreApp-wide singletons (auth, interceptors, guards)Provided once at root
sharedStateless reusable UI, pipes, directivesImported per component
features/*Vertical slices with their own routes & servicesLazy-loaded per route

Tip: Keep shared free of business logic and feature-specific services. If something in shared imports from features, it doesn’t belong in shared.

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, omit providedIn and list it in the route’s providers array.

Best Practices

  • Organize by feature (vertical slices), not by file type — colocate components, services, routes, and models.
  • Give every feature its own *.routes.ts and lazy-load it from the root via loadChildren to shrink the initial bundle.
  • Reserve core/ for app-wide singletons and shared/ for stateless, reusable UI — keep both free of feature-specific logic.
  • Scope feature state with route-level providers instead of providedIn: 'root' so it’s created and destroyed with the feature.
  • Use a barrel-free, shallow import strategy: import components directly in loadComponent to preserve effective tree-shaking and code splitting.
  • Keep feature folders dependency-free of one another; share through shared/ or core/, never feature-to-feature imports.
Last updated June 14, 2026
Was this helpful?