Angular Project Architecture: Features, Folders and Clean Boundaries

Angular Tutorial: Beginner to Advanced · 15. Architecture, configuration and errors · 4 min read · Updated

Small apps work with any structure. Large apps need rules, or they become tangled. This lesson shows a practical, widely used way to organise an Angular application.

What you will learn

  • A feature-based folder structure
  • Smart (container) and presentational components
  • Facades, boundaries and tools that enforce the rules

Organise by feature, not by type

Avoid folders such as components/, services/, pipes/ holding everything. Group files by what they are for:

src/app/
  core/                 app-wide singletons: auth, interceptors, config
    auth/
    http/
  shared/               small, reusable, domain-free UI and utilities
    ui/button/
    pipes/
  features/
    products/           one business area
      data/             services, models, state
      ui/               presentational components
      pages/            routed (container) components
      products.routes.ts
    checkout/
    account/
  app.routes.ts
  app.config.ts

Rules that keep it healthy:

  1. A feature may use core and shared, never another feature's internals.
  2. shared must not import from features.
  3. Each feature exposes its routes (lazy loaded) and, if needed, a small public API.
  4. Keep files small. Split a component when its template exceeds a screen.

Smart and presentational components

  • Container (smart) components talk to services and stores, and own the state.
  • Presentational (dumb) components only receive inputs and emit outputs. They are easy to reuse and test.
// src/app/features/products/ui/product-card.ts
import { ChangeDetectionStrategy, Component, input, output } from '@angular/core';

export interface ProductView {
  id: number;
  name: string;
  price: number;
}

@Component({
  selector: 'app-product-card',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <article>
      <h3>{{ product().name }}</h3>
      <p>{{ product().price }} USD</p>
      <button type="button" (click)="add.emit(product().id)">Add to cart</button>
    </article>
  `,
})
export class ProductCard {
  readonly product = input.required<ProductView>();
  readonly add = output<number>();
}
// src/app/features/products/data/products.facade.ts
import { Injectable, computed, signal } from '@angular/core';
import { ProductView } from '../ui/product-card';

// A facade is the single, simple API a feature offers to its pages.
@Injectable({ providedIn: 'root' })
export class ProductsFacade {
  private readonly items = signal<ProductView[]>([
    { id: 1, name: 'Notebook', price: 5 },
    { id: 2, name: 'Backpack', price: 40 },
  ]);
  private readonly cartIds = signal<number[]>([]);

  readonly products = this.items.asReadonly();
  readonly cartCount = computed(() => this.cartIds().length);

  addToCart(id: number): void {
    this.cartIds.update((ids) => [...ids, id]);
  }
}
// src/app/features/products/pages/products-page.ts
import { Component, inject } from '@angular/core';
import { ProductsFacade } from '../data/products.facade';
import { ProductCard } from '../ui/product-card';

@Component({
  selector: 'app-products-page',
  imports: [ProductCard],
  template: `
    <h2>Products ({{ facade.cartCount() }} in cart)</h2>
    @for (p of facade.products(); track p.id) {
      <app-product-card [product]="p" (add)="facade.addToCart($event)" />
    }
  `,
})
export class ProductsPage {
  protected readonly facade = inject(ProductsFacade);
}
// src/app/features/products/products.routes.ts
import { Routes } from '@angular/router';
import { ProductsPage } from './pages/products-page';

export const PRODUCTS_ROUTES: Routes = [{ path: '', component: ProductsPage, title: 'Products' }];
// src/app/app.routes.ts
import { Routes } from '@angular/router';

export const routes: Routes = [
  { path: '', pathMatch: 'full', redirectTo: 'products' },
  {
    path: 'products',
    loadChildren: () => import('./features/products/products.routes').then((m) => m.PRODUCTS_ROUTES),
  },
  { path: '**', redirectTo: 'products' },
];
// src/app/app.ts
import { Component } from '@angular/core';
import { RouterOutlet } from '@angular/router';

@Component({
  imports: [RouterOutlet],
  selector: 'app-root',
  templateUrl: './app.html',
  styleUrl: './app.css',
})
export class App {}
<!-- src/app/app.html -->
<main>
  <router-outlet />
</main>

The page knows about the facade. The card knows nothing about services. The facade hides how data is stored, so you can later replace the signal with an HTTP call without touching components.

Tools that enforce the rules

  • Strict TypeScript. Keep "strict": true, and strictTemplates in angularCompilerOptions. New projects have them.
  • ESLint. ng add angular-eslint adds template and TypeScript rules.
  • Import boundaries. Use ESLint rules (such as no-restricted-imports) or a monorepo tool like Nx to block shared from importing features.
  • Prettier for formatting, so reviews focus on logic.
  • Path aliases in tsconfig.json (for example @core/*) to avoid long relative imports.
  • Conventions in writing. A short ARCHITECTURE.md in the repo helps every new teammate.

When is this too much?

Small apps do not need facades or strict boundaries. Start simple, and add structure when you feel pain: files you cannot find, features tangled together, or tests that need half the app.

Try it yourself

  1. Move one feature of your Task Manager into a features/tasks folder with data, ui and pages.
  2. Lazy load it from app.routes.ts.
  3. Add ESLint and write one rule that blocks shared from importing features.

Quick check

  1. Why group by feature rather than by file type?
  2. What is the job of a presentational component?
  3. What problem does a facade solve?

Next, continue with Angular App Configuration, Environment Variables and provideAppInitializer.

Frequently asked questions

Should I still use NgModules?

New code uses standalone components and route-level providers. You only meet NgModules in older projects.

Do I need Nx or a monorepo?

Not for one app. Consider it when several apps and libraries share code and you want enforced boundaries and faster, cached builds.