Source of truth for the SaaS Business App frontend. This document describes the architecture as it exists in the codebase today, with exact file references. Where a capability is planned but not yet implemented, it is explicitly marked as such.
The application is a production-grade enterprise SaaS frontend built with Angular 22.0.7 standalone components and signals. It combines four complementary methodologies:
| Methodology | Where it applies | Benefit |
|---|---|---|
| Domain-Driven Design (DDD) | Inside each feature (domain/, application/, infrastructure/, presentation/) |
Ubiquitous language, protected business invariants |
| Vertical Slice Architecture | src/app/features/<feature>/ |
Feature isolation, independent teams, removable slices |
| Clean Architecture | Dependency direction presentation -> application -> domain <- infrastructure |
Domain has zero framework/IO dependencies |
| Atomic Design | src/app/ui/{atoms,molecules,organisms} |
Composable, reusable, accessible UI |
High-level dependency flow:
+----------------------------------------------------------+
| presentation/ (pages, components) |
| uses SignalStore + signals |
+----------------------|-----------------------------------+
v
+----------------------------------------------------------+
| application/ (store facade, DTOs, mappers, use cases) |
+----------------------|-----------------------------------+
v
+----------------------------------------------------------+
| domain/ (entities, repository interfaces, enums) |
| *** pure TypeScript, no Angular IO *** |
+----------------------^-----------------------------------+
| implements interfaces
+----------------------------------------------------------+
| infrastructure/(http: ApiService -> HttpClient, storage) |
+----------------------------------------------------------+
The domain layer depends on nothing. Infrastructure implements domain repository interfaces. Presentation and application depend on domain, never the other way around.
| Category | Technology |
|---|---|
| Framework | Angular 22.0.7 (standalone, signals, esbuild) |
| Language | TypeScript ~6.0.2 (strict) |
| State | NgRx SignalStore 21.1.1, RxJS ~7.8 (HTTP streams) |
| UI | Atomic Design, Angular Material 22, Angular CDK 22 |
| Styling | Tailwind CSS v4 (PostCSS), SCSS, CSS custom properties |
| Build | @angular/build:application (esbuild) |
| API mocking | MSW 2.x (src/mocks/) |
| Testing | Vitest (unit), Playwright (E2E, planned) |
| Lint/format | ESLint flat config, Prettier |
| Git hooks | Husky, commitlint, lint-staged |
| CI/CD | GitHub Actions, Docker (multi-stage), Nginx |
| Runtime | Node 24 (build), Nginx (serve) |
Every feature under src/app/features/<feature>/ mirrors the same four layers.
Example: src/app/features/authentication/
features/authentication/
├── domain/
│ ├── entities/ # SessionEntity, UserEntity, AuthTokenEntity (pure TS)
│ └── repository/ # IAuthRepository interface (contract only)
├── application/
│ ├── dto/ # LoginRequestDto, LoginResponseDto, RegisterRequestDto (snake_case)
│ ├── mappers/ # AuthMapper: DTO -> Entity
│ └── facade/ # AuthStore (SignalStore) - the use-case entry point
├── infrastructure/
│ └── http/ # AuthApiService implements IAuthRepository, AuthRefreshService,
│ # authInterceptor
└── presentation/
├── pages/ # LoginPage, RegisterPage, ForgotPasswordPage
└── guards/ # authGuard (CanMatchFn)
| Layer | Responsibility | Allowed to depend on |
|---|---|---|
domain/ |
Entities, value objects, repository interfaces, enums, domain exceptions. Pure TypeScript, no @angular/* imports. |
nothing |
application/ |
Use-case orchestration via SignalStore facades, DTOs (wire shapes), mappers (DTO <-> entity). | domain/ |
infrastructure/ |
Repository implementations (*ApiService), HTTP concerns, storage adapters, interceptors. Implements domain interfaces. |
domain/, application/ (mappers/DTOs), shared infrastructure/ |
presentation/ |
Pages, components, guards, route definitions. Consumes the store facade. | application/ (store), domain/ (entities), ui/, layout/ |
Feature isolation rule: features communicate only through shared domain entities
(src/app/domain/) or through their public store facade. A feature must never import
another feature's infrastructure or presentation code.
Three complementary state primitives, each used where it fits best:
| Primitive | Used for | Location |
|---|---|---|
| NgRx SignalStore | Feature/global state with async side effects | features/<f>/application/facade/*.store.ts |
Angular signals (signal, computed) |
Local component/UI state | Inside components |
RxJS Observable |
HTTP streams only | infrastructure/http/ |
Every store is signalStore({ providedIn: 'root' }, withState(...), withComputed(...)?, withMethods(...)).
Async work is exposed as rxMethod so components can call store.load() directly without
manual subscription. Example: src/app/features/dashboard/application/facade/dashboard.store.ts:
export const DashboardStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withMethods((store, api = inject(DashboardApiService)) => ({
load: rxMethod<void>(
pipe(
tap(() => patchState(store, { isLoading: true, error: null })),
switchMap(() =>
api.getStats().pipe(
tap((stats) => patchState(store, { stats, isLoading: false })),
catchError((err) => {
patchState(store, { isLoading: false, error: err.message });
return of(undefined);
}),
),
),
),
),
})),
);A withMethods factory only sees previously registered methods on its store
argument, never the ones it defines in the same block. Therefore:
rxMethoddefinitions and sync methods that call other store methods must live in separatewithMethodsblocks, ordered so callees are defined before callers.- Sync methods with no cross-method dependency (e.g.
logout,clearError) may share a block with anrxMethod.
Verified pattern in src/app/features/users/application/facade/users.store.ts:
export const UsersStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
// Block 1: defines `load` (rxMethod). No reference to other store methods.
withMethods((store, api = inject(UsersApiService)) => ({
load: rxMethod<void>(pipe(/* ... api.getAll(...) ... */)),
})),
// Block 2: `setPage`/`setSearch` call store.load(), so they MUST be in a later block.
withMethods((store, api = inject(UsersApiService)) => ({
deleteUser: rxMethod<string>(pipe(/* ... store.load() ... */)),
setPage: (page: number): void => {
patchState(store, { page });
store.load();
},
setSearch: (search: string): void => {
patchState(store, { search, page: 1 });
store.load();
},
})),
);AuthStore additionally uses withComputed to derive fullName and isAdmin from the
user state signal (src/app/features/authentication/application/facade/auth.store.ts:30).
All HTTP traffic flows through src/app/infrastructure/http/api.service.ts, which wraps
HttpClient. No feature calls HttpClient directly. Every call returns a Result<T>
discriminated union defined in src/app/infrastructure/http/result.ts:
export type Result<T> = Success<T> | Failure;
export interface Success<T> {
data: T;
error: null;
isSuccess: true;
isFailure: false;
}
export interface Failure {
data: null;
error: Error;
isSuccess: false;
isFailure: true;
}ApiService.request<T> applies, in order: timeout(config.timeout), retry(config.retries),
map(success), and catchError -> failure(Error). The config is provided via the
API_CONFIG token (src/app/infrastructure/http/api-config.ts): baseUrl, timeout
(default 30000), retryAttempts (default 0), retryDelay (default 1000). The
baseUrl is bound to environment.apiBaseUrl in app.config.ts.
Each feature declares a repository interface in domain/repository/ and an
implementation in infrastructure/http/. The implementation injects ApiService,
unwraps the Result<T> (throwing result.error on failure so the store's catchError
can react), and maps DTOs to entities via an injectable Mapper.
Page/Store -> *ApiService (implements IXRepository)
|-- inject(ApiService) // returns Result<Dto>
|-- inject(XMapper) // Dto -> Entity
`-- map: if (result.isSuccess) return mapper.toEntity(result.data);
else throw result.error;
| Concern | Authentication example | Users example |
|---|---|---|
| Interface | features/authentication/domain/repository/iauth.repository.ts (IAuthRepository) |
features/users/domain/repository/iuser.repository.ts (IUserRepository, PaginatedResponse<T>) |
| Implementation | features/authentication/infrastructure/http/auth-api.service.ts (AuthApiService) |
features/users/infrastructure/http/users-api.service.ts (UsersApiService) |
| Mapper | features/authentication/application/mappers/auth.mapper.ts (AuthMapper) |
features/users/application/mappers/user.mapper.ts (UserMapper) |
| DTO | LoginResponseDto (snake_case: access_token, first_name, ...) |
UserDto, PaginatedUsersDto |
DTOs mirror the backend wire format (snake_case). Entities use camelCase and rich
types (Date, enums). Mappers are the only place that knows the wire shape. Example
from AuthMapper.toUser:
return {
id: dto.id,
email: dto.email,
firstName: dto.first_name, // snake_case -> camelCase
lastName: dto.last_name,
role: dto.role as UserEntity['role'],
createdAt: new Date(dto.created_at), // ISO string -> Date
updatedAt: new Date(dto.updated_at),
};Because repository interfaces are typed against entities, a feature's repository can be
swapped for a mock/in-memory implementation without touching the store or presentation
(the project ships MSW handlers in src/mocks/handlers.ts for this purpose).
JWT access token + refresh token, with a swappable storage backend and single-flight refresh. All auth infrastructure lives inside the authentication feature.
+--------------------+ provideAppInitializer (app.config.ts)
| AuthStore (root) |<------- restores session before first render:
| user/isAuth/isErr | firstValueFrom(authStore.restoreSession())
+----+---------------+ |
| login()/logout() v
v AuthApiService.getCurrentUser()
AuthApiService.login() -> /auth/me (Bearer token from AUTH_STORAGE)
-> /auth/login |
| SessionEntity v
v patchState({ user, isAuthenticated })
AUTH_STORAGE.setSession(at, rt)
| Component | File | Role |
|---|---|---|
AuthStore |
features/authentication/application/facade/auth.store.ts |
SignalStore: login (rxMethod), logout, clearError, restoreSession; computes fullName, isAdmin |
IAuthRepository / AuthApiService |
domain/repository/iauth.repository.ts, infrastructure/http/auth-api.service.ts |
Contract + HTTP impl for login, register, refreshToken, logout, getCurrentUser |
AUTH_STORAGE token |
infrastructure/storage/auth-storage.ts |
Abstraction over token storage (getAccessToken, getRefreshToken, setSession, clear) |
LocalStorageAuthStorage |
infrastructure/storage/local-storage-auth-storage.ts |
Default impl; stores access_token / refresh_token via shared StorageService |
CookieAuthStorage |
infrastructure/storage/cookie-auth-storage.ts |
Alternative impl for HttpOnly cookies; setSession is a no-op (backend Set-Cookie), reads/erases non-HttpOnly cookies |
AuthRefreshService |
infrastructure/http/auth-refresh.service.ts |
Single-flight refresh: caches the in-flight refresh() via shareReplay(1) + finalize, exposes getAccessToken/getRefreshToken/clearSession |
authInterceptor |
infrastructure/http/auth.interceptor.ts |
Attaches Authorization: Bearer; on 401 refreshes once and replays the original request (RETRIED HttpContextToken guard against loops) |
authGuard |
presentation/guards/auth.guard.ts |
CanMatchFn that allows routing when store.isAuthenticated() is true, else redirects to /login |
Wiring in src/app/app.config.ts:
provideAppInitializer(() => {
const authStore = inject(AuthStore);
return firstValueFrom(authStore.restoreSession());
}),
provideHttpClient(withFetch(), withInterceptors([authInterceptor, errorInterceptor, loggingInterceptor])),
{ provide: AUTH_STORAGE, useClass: LocalStorageAuthStorage }, // swap to CookieAuthStorage here- Skip token handling for
/auth/refreshrequests. - Clone the request with
Authorization: Bearer <token>when a token is present. - On a
401 HttpErrorResponse: if alreadyRETRIED, re-throw; if no refresh token, clear session and redirect to/login; otherwise callAuthRefreshService.refresh()(deduplicated), then replay the original request with the new token andRETRIED=true. - On refresh failure: clear session, redirect to
/login, re-throw the original error.
The
/approute group is protected withcanMatch: [authGuard]. Session state is available before routing becauseprovideAppInitializerrunsrestoreSession()first.
Three layers, each for a different class of error:
| Layer | Mechanism | File | Handles |
|---|---|---|---|
| Expected API errors | Result<T> discriminated union |
infrastructure/http/result.ts |
Network/HTTP failures become Failure values; stores branch on isSuccess/isFailure |
| HTTP 403 | errorInterceptor |
core/interceptors/error.interceptor.ts |
Navigates to /403; logs HTTP <status> |
| Uncaught exceptions | GlobalErrorHandler |
core/error/global-error.handler.ts |
Logs via LOGGER, navigates to /error (inside NgZone if unstable), then delegates to super.handleError() |
GlobalErrorHandler is registered as the app ErrorHandler in app.config.ts:
{ provide: ErrorHandler, useClass: GlobalErrorHandler },loggingInterceptor (core/interceptors/logging.interceptor.ts) records per-request
performance via logger.perf(...). Interceptor order is
[authInterceptor, errorInterceptor, loggingInterceptor].
Routing surfaces for errors live in src/app/layout/pages/:
forbidden/forbidden.component.ts (/403), error/error.component.ts (/error),
not-found/not-found.component.ts (/**).
src/app/app.routes.ts defines a lazy-loaded, standalone-component route tree with two
layout shells:
/ (PublicLayoutComponent) /app (PrivateLayoutComponent)
├── '' -> /login ├── '' -> /app/dashboard
├── /login LoginPage ├── /dashboard DashboardPage
└── /register RegisterPage ├── /users loadChildren(users.routes)
└── /forgot-password ... ├── /products loadChildren(products.routes)
├── /orders loadChildren(orders.routes)
├── /notifications NotificationsPage
└── /profile ProfilePage
/403 ForbiddenComponent /error ErrorPage /** NotFoundComponent
- Every route uses
loadComponent/loadChildrenfor code splitting. withComponentInputBinding()is enabled, so route params bind to componentinput()s.- Feature sub-routes are colocated, e.g.
features/users/users.routes.ts(default export) with''(list),new(form),:id(form). - Public and private shells are
src/app/layout/public-layout/andsrc/app/layout/private-layout/; shared chrome (sidebar,topbar,breadcrumb) lives insrc/app/layout/components/.
src/app/ui/
├── atoms/ button, input (ControlValueAccessor), badge, avatar, spinner
├── molecules/ card, toast
└── organisms/ data-table, dialog, form-field, empty-state, error-state
Each UI component is:
- Standalone (
standalone: true) - OnPush (
changeDetection: ChangeDetectionStrategy.OnPush) - Signal-based inputs:
input()for one-way,model()for two-way (seeui/atoms/input/input.component.ts:132usingmodel<string>) - Control flow:
@if/@forin templates (no*ngIf/*ngFor) - Accessible: ARIA attributes and semantic HTML (e.g.
ButtonComponentsetsaria-busy/aria-disabled;InputComponentsetsaria-invalid/aria-describedbyandrole="alert"on its error message) - Token-driven: styles reference CSS custom properties from
styles.scss(no hardcoded colors/sizes)
InputComponent registers NG_VALUE_ACCESSOR so it works with both template-driven and
reactive forms. Variants/sizes are applied via :host-context(...) host classes driven by
signal inputs (see ui/atoms/button/button.component.ts).
src/styles.scss defines the entire token system as CSS custom properties on :root
(light) and [data-theme='dark'] (dark):
- Color:
--color-primary,--color-success/warning/danger/info, surface, text, border - Typography:
--text-xs..--text-4xlwith matching*-lhline-heights,--font-sans/mono - Spacing:
--spacing-0..--spacing-20 - Radius:
--radius-sm/md/lg/xl/2xl/full - Shadow:
--shadow-xs..--shadow-2xl - Motion:
--easing-default/spring,--duration-fast/normal/slow
ThemeService (src/app/core/services/theme.service.ts) drives dark mode with a signal +
effect: it sets document.documentElement.setAttribute('data-theme', theme), persists
to localStorage, and seeds from prefers-color-scheme. Tailwind v4 is loaded via
PostCSS (.postcssrc.json -> @tailwindcss/postcss) through src/tailwind.css
(@import 'tailwindcss';); styles.scss and tailwind.css are both listed in
angular.json styles.
The project uses runtime i18n (decision recorded in ADR-005) so the language can be switched at runtime without per-locale builds.
src/app/core/i18n/translate.service.ts— a signal-basedTranslateServicewithlocale()andisRtl()computed signals,setLocale('en' | 'ar'), and atranslate(key, params?)method supporting{{param}}interpolation. On construction it reads the saved locale fromlocalStorageand appliesdocument.documentElement.dir(rtl/ltr) andlangto avoid a RTL flash.src/app/core/i18n/translate.pipe.ts— a standalonetranslatepipe (pure: false) used in templates:{{ 'nav.dashboard' | translate }}.src/app/core/i18n/translations.ts—enandardictionaries (Record<string, string>) with keys for the app shell, navigation, topbar, auth pages, and error pages.- Language switch — a globe button in the topbar (
layout/components/topbar) togglesen↔ar; thedirattribute on<html>drives RTL layout. - RTL —
[dir="rtl"]rules instyles.scssplus logical CSS properties (border-inline-end,inset-inline-end) in the sidebar/topbar.
Translated so far: sidebar navigation, topbar actions, login page, and the 403/404/500 error
pages. Other features follow the same {{ 'key' | translate }} pattern.
Configured in angular.json under projects.saas-business-app.architect.build:
- Builder:
@angular/build:application(esbuild + PostCSS). - Entry:
src/main.ts; tsConfig:tsconfig.app.json. - Styles:
src/tailwind.css,src/styles.scss(inlineStyleLanguage: scss). - Assets:
public/**/*(favicon,mockServiceWorker.js). - Production budgets:
initialwarns at 500 kB / errors at 1 MB;anyComponentStylewarns at 4 kB / errors at 8 kB. - Output hashing:
all(filenames content-hashed for long-term caching). - Default configuration:
production;developmentdisables optimization and enables source maps. - Output:
dist/saas-business-app/browser/.
main.ts starts the MSW mock worker in browser contexts before
bootstrapApplication(App, appConfig), so the app runs against mock APIs in development.
Multi-stage Dockerfile:
- Build (
node:24-alpine):npm ci,npm run build. - Serve (
nginx:alpine): copiesdist/saas-business-app/browserto/usr/share/nginx/html, appliesnginx.conf, runs as the non-rootnginxuser, exposes80, and defines awgethealthcheck.
nginx.conf provides:
- Security headers:
Content-Security-Policy,X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Referrer-Policy,Permissions-Policy,Strict-Transport-Security. - Gzip: level 6 for text, JSON, JS, XML, SVG.
- SPA fallback:
location / { try_files $uri $uri/ /index.html; }. - Long-term caching:
expires 1yforjs|css|svg|woff2?(safe because ofoutputHashing: all);index.htmlis always revalidated (expires 0). - API reverse proxy:
location /api/ { proxy_pass http://backend:3000; }.
docker-compose.yml defines app (this image, port 80, healthcheck) and a backend
service (placeholder image your-backend-image:3000) that the /api/ proxy targets.
.github/workflows/ci.yml triggers on push/PR to main and runs three parallel jobs on
Ubuntu with Node 24 and npm cache:
| Job | Steps |
|---|---|
lint |
npm ci -> npm run lint |
test |
npm ci -> npm test -- --no-watch |
build |
npm ci -> npm run build -> upload dist/ artifact |
| Concern | File / token | Notes |
|---|---|---|
| DI providers | src/app/app.config.ts |
App initializer, router, HTTP interceptors, ErrorHandler, API_CONFIG, AUTH_STORAGE, LOGGER, APP_NAME/VERSION/API_BASE_URL |
| App tokens | src/app/core/tokens/app.tokens.ts |
APP_NAME, APP_VERSION, API_BASE_URL |
| Logger | src/app/infrastructure/logger/logger.service.ts |
LOGGER token + ConsoleLoggerService (debug/info/warn/error/perf) |
| Storage | src/app/infrastructure/storage/storage.service.ts |
SSR-safe localStorage JSON wrapper used by LocalStorageAuthStorage |
| Feature flags | src/app/config/feature-flags.ts |
darkMode, notifications, exportData, advancedFilters |
| Environments | src/environments/environment{.prod,.staging,}.ts |
production, apiBaseUrl, appName, appVersion (dev http://localhost:3000/api/v1, prod /api/v1) |
| Path aliases | tsconfig.json paths |
@core, @domain, @infrastructure, @features, @ui, @layout, @config, @env, @shared (unused) |
| Route | Feature | Store | Status |
|---|---|---|---|
/auth/login, /auth/register, /auth/forgot-password |
authentication | AuthStore |
Done |
/app/dashboard |
dashboard | DashboardStore |
Done |
/app/users (list/new/:id) |
users | UsersStore |
Done |
/app/products |
products | ProductsStore |
Done |
/app/orders |
orders | OrdersStore |
Done |
/app/notifications |
notifications | NotificationsStore |
Done |
/app/profile |
profile | ProfileStore |
Done |
/403, /error, /** |
layout error pages | - | Done |