A production-ready Clean Architecture + MVVM template for Ionic / Capacitor
Built with Angular signals, zoneless change detection, standalone components, and Observable-based storage for Capacitor SQLite compatibility.
just setup # nvm use + npm install (configures husky automatically)
npm start # Dev server β http://localhost:8100The project enforces a strict dependency rule: outer layers depend on inner layers, never the reverse.
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β PRESENTATION β
β Ionic 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, Observable) + CapacitorStorageService
β βββ 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/ # Domain-specific errors per feature
β β βββ auth/ # InvalidCredentialsError, SessionExpiredError
β β βββ products/ # ProductNotFoundError
β βββ 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 (IonRouterOutlet)
β β βββ app.config.ts # Root providers (DI, router, i18n, Ionic)
β β βββ app.routes.ts
β βββ shared/
β βββ components/ # DetailHeader (reusable across views)
β
βββ tests/ # Mirrors src/ structure
βββ core/
βββ data/
βββ domain/
βββ presentation/
All StorageSource methods return Observable<T> β the critical difference from a standard Angular app. Synchronous storage would not be compatible with Capacitor SQLite or other async native storage plugins.
// Abstract contract β all implementations must be Observable
abstract class StorageSource {
abstract get<T>(key: string): Observable<T | null>;
abstract set<T>(key: string, value: T): Observable<void>;
abstract remove(key: string): Observable<void>;
}Repository implementations chain storage operations with switchMap instead of direct calls:
// auth-implementation.repository.ts
override login(username: string, password: string): Observable<LoginEntity> {
return this.remote.login(username, password).pipe(
map((dto) => this.loginMapper.mapFrom(dto)),
switchMap((entity) =>
this.local.saveTokens(tokensDbo).pipe(map(() => entity)),
),
);
}Replaces the Angular IntersectionObserver + sentinel pattern with IonInfiniteScroll + @ViewChild + effect():
@ViewChild(IonInfiniteScroll) private readonly infiniteScroll?: IonInfiniteScroll;
constructor() {
// Complete the Ionic spinner when loading finishes
effect(() => {
if (!this.vm.viewState.isLoading()) {
this.infiniteScroll?.complete();
}
});
}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)
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 (ion-content, ion-infinite-scroll)
βββ products-list-view.scss β styles
Each feature registers its own providers via a provideXxxDI() function scoped to the route β no global pollution:
// public-layout.routes.ts
{
path: '',
providers: [provideProductsDI()],
loadComponent: () => import('./views/products-list-view/...')
}The local datasource persists a ProductsDbo with cachedAt embedded and invalidates it after 1 hour:
// save (fire-and-forget)
saveProducts(skip: number, data: ProductsDbo): void {
this.storage.set(this.cacheKey(skip), data).subscribe();
}
// read β returns null if stale
getProducts(skip: number): Observable<ProductsDbo | null> {
return this.storage.get<ProductsDbo>(this.cacheKey(skip)).pipe(
map((cached) => {
if (!cached) return null;
if (Date.now() - cached.cachedAt > PRODUCTS_CACHE_TTL_MS) return null;
return cached;
}),
);
}Errors flow through three layers, each adding more specificity:
HTTP response
β publicInterceptor: maps HTTP status β core AppError
Core error (NetworkError, BadRequestError, UnauthorizedErrorβ¦)
β repository: catchError + instanceof checks β domain error
Domain error (InvalidCredentialsError, ProductNotFoundErrorβ¦)
β usecase: passes AppError through, generic fallback otherwise
ViewModel: err instanceof AppError ? err.messageKey : 'errors.unknown'
β
{{ error() | translate }}
Core errors β generic infrastructure (src/core/errors/):
| Class | HTTP Status | i18n Key |
|---|---|---|
NetworkError |
0 |
errors.network |
BadRequestError |
400 |
errors.unknown |
UnauthorizedError |
401 |
errors.unauthorized |
NotFoundError |
404 |
errors.not_found |
ServerError |
5xx |
errors.server |
Domain errors β business-specific (src/domain/errors/):
| Class | Extends | i18n Key |
|---|---|---|
InvalidCredentialsError |
UnauthorizedError |
errors.auth.invalid_credentials |
SessionExpiredError |
UnauthorizedError |
errors.auth.session_expired |
ProductNotFoundError |
NotFoundError |
errors.products.not_found |
publicInterceptor converts every HttpErrorResponse into a typed AppError before it reaches the repository. Repositories then check instanceof to map core errors to domain-specific ones. ViewModels read err.messageKey directly β no mapping needed at the presentation layer.
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.
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 runs without Angular TestBed. Components that need DI use TestBed.configureTestingModule.
| Command | Description |
|---|---|
just setup |
Set Node version via nvm + install dependencies |
npm start |
Dev server at localhost:8100 |
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 web build |
just sync |
Build web + sync to native projects |
just add-android |
Create Android project (first time only) |
just add-ios |
Create iOS project (first time only) |
just android |
Sync + open Android Studio |
just ios |
Sync + open Xcode |
- 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