A production-ready Clean Architecture + MVVM template for Angular
Built with the latest Angular features: signals, zoneless change detection, and standalone components.
just setup # nvm use + npm install (configures husky automatically)
npm start # Dev server → http://localhost:4200The project enforces a strict dependency rule: outer layers depend on inner layers, never the reverse.
╔═══════════════════════════════════════════════════════════════╗
║ PRESENTATION ║
║ Components · ViewModels · Signals State ║
╠═══════════════════════════════════════════════════════════════╣
║ DOMAIN ║
║ Entities · Repositories (abstract) · UseCases ║
╠═══════════════════════════════════════════════════════════════╣
║ DATA ║
║ Repositories (impl) · DataSources · Mappers ║
╠═══════════════════════════════════════════════════════════════╣
║ CORE ║
║ Interfaces · Utils · Interceptors · Errors ║
╚═══════════════════════════════════════════════════════════════╝
dependency arrow points inward ↑
src/
├── core/ # Framework-agnostic utilities
│ ├── assets/ # Static assets (i18n, icons…)
│ │ └── i18n/en.json
│ ├── core-interface/ # UseCase, Mapper, ViewState interfaces
│ ├── directives/ # ImgFallbackDirective
│ ├── environments/ # environment.ts / environment.prod.ts
│ ├── errors/ # AppError, NetworkError, UnauthorizedError…
│ ├── guards/ # AuthGuard, GuestGuard
│ ├── interceptors/ # publicInterceptor, authInterceptor
│ ├── pipes/ # PricePipe
│ ├── services/storage/ # StorageSource (abstract) + LocalStorageService
│ └── utils/ # calcOriginalPrice
│
├── data/ # Infrastructure layer
│ ├── datasource/
│ │ ├── products/
│ │ │ ├── remote/
│ │ │ │ ├── dto/ # ProductDto, ProductsDto (API models)
│ │ │ │ └── products-remote.datasource.imp.ts
│ │ │ ├── local/
│ │ │ │ ├── dbo/ # ProductDbo, ProductsDbo (local storage models)
│ │ │ │ └── products-local.datasource.imp.ts
│ │ │ └── source/ # Abstract datasource contracts
│ │ └── auth/ # Same structure (remote/dto, local/dbo)
│ ├── repositories/
│ │ ├── products/
│ │ │ ├── mappers/ # ProductDtoToEntityMapper, ProductDboToEntityMapper
│ │ │ └── products-implementation.repository.ts
│ │ └── auth/
│ │ ├── mappers/ # LoginDtoToEntityMapper, TokensDboToEntityMapper
│ │ └── auth-implementation.repository.ts
│ └── di/ # provideProductsDI(), provideAuthDI()
│
├── domain/ # Business rules — zero framework dependencies
│ ├── entities/ # ProductEntity, UserEntity, LoginEntity…
│ ├── errors/ # InvalidCredentialsError, SessionExpiredError…
│ ├── repositories/ # Abstract repository contracts
│ └── usecases/ # GetProductsUseCase, LoginUseCase…
│
├── presentation/ # UI layer
│ ├── app/
│ │ ├── views/
│ │ │ ├── products-list-view/
│ │ │ │ ├── components/ # ProductCard, ProductsGrid, ProductsHeader…
│ │ │ │ └── viewmodel/ # products.state.ts, products.viewmodel.ts
│ │ │ ├── product-detail-view/
│ │ │ │ ├── components/ # ProductGallery, ProductInfo…
│ │ │ │ └── viewmodel/
│ │ │ ├── user-detail-view/
│ │ │ │ ├── components/ # UserProfileCard…
│ │ │ │ └── viewmodel/
│ │ │ └── login-view/
│ │ │ ├── components/ # LoginForm, LoginHeader, LoginFooter
│ │ │ └── viewmodel/
│ │ ├── layouts/ # PublicLayout, PrivateLayout
│ │ ├── app.config.ts # Root providers (DI, router, i18n)
│ │ └── app.routes.ts
│ └── shared/
│ ├── components/ # DetailHeader (reusable across views)
│ └── modals/
│
└── tests/ # Mirrors src/ structure
├── core/
├── data/
├── domain/
└── presentation/
Each datasource owns its own data model. DTOs and DBOs are never shared between layers:
Remote datasource → DTO → DtoToEntityMapper → Entity
Local datasource → DBO → DboToEntityMapper → Entity
- DTO (
remote/dto/) — mirrors the API response shape - DBO (
local/dbo/) — models what is persisted in local storage (e.g. includescachedAt)
This decouples the API contract from the storage format. A change in the server response only affects the DTO and its mapper, never the cached data structure.
Each view is split into three files with clear responsibilities:
views/products-list-view/
├── components/
│ └── product-card/
│ ├── product-card.ts ← component class
│ ├── product-card.html ← template
│ └── product-card.scss ← styles
├── viewmodel/
│ ├── products.state.ts ← signals (single source of truth)
│ └── products.viewmodel.ts ← orchestrates usecase calls + state updates
├── products-list-view.ts ← component class, reads viewState signals
├── products-list-view.html ← template
└── products-list-view.scss ← styles
Each feature registers its own providers via a provideXxxDI() function scoped to the route — no global pollution:
// private-layout.routes.ts
{
path: 'products',
providers: [provideProductsDI()],
loadComponent: () => import('./views/products-list-view/...')
}The local datasource persists a ProductsDbo with cachedAt embedded and invalidates it after 1 hour:
// save
const dbo: ProductsDbo = { ...products, cachedAt: Date.now() };
// read — returns null if stale
if (Date.now() - cached.cachedAt > PRODUCTS_CACHE_TTL_MS) return null;Errors flow through three layers with increasing specificity:
HTTP response
↓ interceptor → core AppError subclass
repository → domain-specific error via catchError
↓ usecase passes AppErrors through, wraps unexpected errors
viewmodel stores err.messageKey
↓ template renders
{{ error | translate }}
Core errors (src/core/errors/app-error.ts):
| Class | HTTP Status | i18n Key |
|---|---|---|
NetworkError |
0 |
errors.network |
BadRequestError |
400 |
— |
UnauthorizedError |
401 |
errors.unauthorized |
NotFoundError |
404 |
errors.not_found |
ServerError |
5xx |
errors.server |
AppError |
default | errors.unknown |
Domain errors (src/domain/errors/):
| Class | Maps from | i18n Key |
|---|---|---|
InvalidCredentialsError |
UnauthorizedError | BadRequestError |
errors.auth.invalid_credentials |
SessionExpiredError |
UnauthorizedError |
errors.auth.session_expired |
ProductNotFoundError |
NotFoundError |
errors.products.not_found |
Translation keys live in src/core/assets/i18n/en.json. Templates use | translate from @ngx-translate/core. ViewModels always store the key, never the translated string — the UI layer owns the translation concern.
just test # Run all tests
just coverage # With coverage report
open coverage/index.html # Open HTML coverage reportTests use Vitest (no Jest, no Karma). Pure logic — usecases, mappers, utils — runs without Angular TestBed. Components that need DI use TestBed.configureTestingModule.
Generate a complete domain layer (entity, repository, usecases, datasources, mappers, DI provider) interactively:
npm run domain| Command | Description |
|---|---|
just setup |
Set Node version via nvm + install dependencies |
npm start |
Dev server at localhost:4200 |
just test |
Run all tests |
just coverage |
Run tests with coverage report |
just lint |
ESLint |
just lint-fix |
ESLint (auto-fix) |
just format |
Prettier (write) |
npm run build |
Production build |
npm run format:check |
Prettier (check only) |
npm run domain |
Generate domain layer scaffold |
- ESLint — enforces
prefer-standaloneandprefer-injectas errors,no-explicit-anyas error - Prettier — auto-formats on commit via lint-staged
- Husky — pre-commit hook runs lint-staged automatically after
just setup - lint-staged — only lints/formats staged files, not the whole project