A good folder structure answers two questions instantly: “where do I find X?” and “where do I put Y?”. The current recommendation (and the one this very site uses) is to organise by domain (features), not by file type, with two cross-cutting areas: core and shared.
The recommended structure
src/app/
├── app.ts · app.config.ts · app.routes.ts
│
├── core/ ← once per application: auth, layout, errors, interceptors
│ ├── auth/
│ ├── layout/
│ └── error-handling/
│
├── shared/ ← reusable, WITHOUT domain
│ ├── ui/ ← generic dumb components
│ ├── directives/
│ ├── pipes/
│ └── util/ ← pure functions
│
└── features/ ← the business domains
├── products/
│ ├── products.routes.ts
│ ├── pages/ ← smart (routed)
│ ├── ui/ ← dumb, domain-specific
│ ├── data-access/ ← api + store
│ └── models/ ← types, DTOs, mappers
├── cart/
└── checkout/The root of the application. Here live only the startup files and the three areas: core, shared, features.
The three areas, in one sentence each
| Area | The test question | May import from |
|---|---|---|
core/ | “Is it used once, at application level, or is it infrastructure?” | shared |
shared/ | “Would it work the same copied into another project?” | only another shared |
features/x/ | “Is it about domain X?” | shared, core (services) |
Inside a feature
Inside a feature, the subfolders follow the roles from Smart vs Dumb: pages (smart, routed), ui (dumb), data-access (api + store) and models (types). Don’t create them from the start — a small feature is fine flat:
// A small feature? No subfolders — flat, until it becomes awkward (~6–8 files)
features/settings/
├── settings.routes.ts
├── settings-page.ts
├── settings-page.html
├── settings-store.ts
├── notification-toggle.ts
└── settings.ts ← the typesLazy loading per feature
// Each feature is lazy: a single entry point (the routes file)
export const routes: Routes = [
{ path: '', loadComponent: () => import('./features/home/home-page') },
{ path: 'products', loadChildren: () => import('./features/products/products.routes') },
{ path: 'cart', loadChildren: () => import('./features/cart/cart.routes') },
{
path: 'admin',
canMatch: [adminGuard],
loadChildren: () => import('./features/admin/admin.routes'),
},
{ path: '**', loadComponent: () => import('./core/layout/not-found') },
];Anti-pattern: folders by type
// ✗ Organisation “by type” — scales badly
src/app/
├── components/ (120 files mixed from all domains)
├── services/ (40)
├── models/ (60)
└── pipes/
// To change “checkout” you open 4 folders and search among hundreds of files.
// You can’t do clean lazy loading, you can’t enforce boundaries between domains.For large monorepos
// A large monorepo (Nx): the same ideas, but as libraries with tags and rules checked by lint
libs/
├── products/
│ ├── feature-list/ type:feature (smart pages)
│ ├── feature-detail/ type:feature
│ ├── ui/ type:ui (dumb)
│ ├── data-access/ type:data-access (api, store)
│ └── util/ type:util
└── shared/
├── ui/ scope:shared, type:ui
└── util/
Typical rules: feature → ui, data-access, util ✓ ui → util ✓ ui → data-access ✕Golden rules
- Colocation: what changes together stays together (the component, its template, styles, test and types).
- Flat until it hurts: add a subfolder when there are too many files, not “for the future”.
- Features don’t import each other. Communicate through
core(global stores),sharedor the URL. See Boundaries. - Each feature has a single entry point: its routes file.
- The folder name = the domain name, plural or singular — but consistent.
Common pitfalls
sharedas a “junk drawer”: everything you don’t know where to put ends up there. See Shared vs feature for clear rules.corewith components reused across pages:coreis for things used once (shell, header), not for common UI.- A single “common” or “general” feature: it is a disguised
shared, with mixed domains.