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:
- A feature may use
coreandshared, never another feature's internals. sharedmust not import fromfeatures.- Each feature exposes its routes (lazy loaded) and, if needed, a small public API.
- 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, andstrictTemplatesinangularCompilerOptions. New projects have them. - ESLint.
ng add angular-eslintadds template and TypeScript rules. - Import boundaries. Use ESLint rules (such as
no-restricted-imports) or a monorepo tool like Nx to blocksharedfrom importingfeatures. - 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.mdin 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
- Move one feature of your Task Manager into a
features/tasksfolder withdata,uiandpages. - Lazy load it from
app.routes.ts. - Add ESLint and write one rule that blocks
sharedfrom importingfeatures.
Quick check
- Why group by feature rather than by file type?
- What is the job of a presentational component?
- What problem does a facade solve?
Next, continue with Angular App Configuration, Environment Variables and provideAppInitializer.