Skip to content

Latest commit

 

History

History
494 lines (401 loc) · 29.4 KB

File metadata and controls

494 lines (401 loc) · 29.4 KB

Architecture

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.

1. Overview

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.

Tech stack (verified from package.json)

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)

2. Layer responsibilities (per feature)

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.

3. State management

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/

SignalStore shape

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);
            }),
          ),
        ),
      ),
    ),
  })),
);

Splitting withMethods blocks

A withMethods factory only sees previously registered methods on its store argument, never the ones it defines in the same block. Therefore:

  • rxMethod definitions and sync methods that call other store methods must live in separate withMethods blocks, ordered so callees are defined before callers.
  • Sync methods with no cross-method dependency (e.g. logout, clearError) may share a block with an rxMethod.

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).

4. API layer

ApiService + Result<T>

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.

Repository interface + implementation + mapper

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

DTO <-> entity mapping convention

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).

5. Authentication flow

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)

Components

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

Refresh-on-401 logic (authInterceptor)

  1. Skip token handling for /auth/refresh requests.
  2. Clone the request with Authorization: Bearer <token> when a token is present.
  3. On a 401 HttpErrorResponse: if already RETRIED, re-throw; if no refresh token, clear session and redirect to /login; otherwise call AuthRefreshService.refresh() (deduplicated), then replay the original request with the new token and RETRIED=true.
  4. On refresh failure: clear session, redirect to /login, re-throw the original error.

The /app route group is protected with canMatch: [authGuard]. Session state is available before routing because provideAppInitializer runs restoreSession() first.

6. Error handling

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 (/**).

7. Routing

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 / loadChildren for code splitting.
  • withComponentInputBinding() is enabled, so route params bind to component input()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/ and src/app/layout/private-layout/; shared chrome (sidebar, topbar, breadcrumb) lives in src/app/layout/components/.

8. UI architecture (Atomic Design)

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 (see ui/atoms/input/input.component.ts:132 using model<string>)
  • Control flow: @if / @for in templates (no *ngIf / *ngFor)
  • Accessible: ARIA attributes and semantic HTML (e.g. ButtonComponent sets aria-busy/aria-disabled; InputComponent sets aria-invalid/aria-describedby and role="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).

Design tokens

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-4xl with matching *-lh line-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.

9. Internationalization (i18n)

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-based TranslateService with locale() and isRtl() computed signals, setLocale('en' | 'ar'), and a translate(key, params?) method supporting {{param}} interpolation. On construction it reads the saved locale from localStorage and applies document.documentElement.dir (rtl/ltr) and lang to avoid a RTL flash.
  • src/app/core/i18n/translate.pipe.ts — a standalone translate pipe (pure: false) used in templates: {{ 'nav.dashboard' | translate }}.
  • src/app/core/i18n/translations.tsen and ar dictionaries (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) toggles enar; the dir attribute on <html> drives RTL layout.
  • RTL[dir="rtl"] rules in styles.scss plus 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.

10. Build

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: initial warns at 500 kB / errors at 1 MB; anyComponentStyle warns at 4 kB / errors at 8 kB.
  • Output hashing: all (filenames content-hashed for long-term caching).
  • Default configuration: production; development disables 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.

11. Deployment (Docker + Nginx)

Multi-stage Dockerfile:

  1. Build (node:24-alpine): npm ci, npm run build.
  2. Serve (nginx:alpine): copies dist/saas-business-app/browser to /usr/share/nginx/html, applies nginx.conf, runs as the non-root nginx user, exposes 80, and defines a wget healthcheck.

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 1y for js|css|svg|woff2? (safe because of outputHashing: all); index.html is 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.

12. CI (GitHub Actions)

.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

13. Cross-cutting configuration

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)

14. Feature inventory

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