Architectural overview
Shape
Internal Admin is a single-page Angular application — one eagerly-loaded bundle, no lazy route loading in the current route tables, hash-based URLs, deployed as static assets to S3 and served behind CloudFront/nginx. It holds a modest amount of shared state in an NgRx store and delegates all business rules that move money to backend services.
┌──────────────────────────────────────────────────────────────────────┐
│ Presentation │
│ MainLayoutComponent shell: sidebar, CRM panel, supplier header, │
│ router outlet, toasts, spinners, dialogs │
│ ~420 components, 18 directives, 12 pipes │
└───────────┬──────────────────────────────────────────┬───────────────┘
│ dispatch(action) / select(selector) │ direct service call
▼ ▼
┌──────────────────────────────────┐ ┌──────────────────────────────────┐
│ State (NgRx 4) │ │ Application services │
│ 12 root slices │ │ ~140 @Injectable services │
│ 6 effect classes │◄──┤ thin HttpClient wrappers │
│ router-store integration │ │ plus orchestration services │
└───────────┬──────────────────────┘ └───────────┬──────────────────────┘
│ │
└──────────────┬───────────────────────┘
▼
┌──────────────────────────────────────────────────────────────────────┐
│ HTTP boundary │
│ AuthInterceptor → attaches X-Session-Token, traps 401 │
│ FetchedRequestsInterceptor → records last 50 requests for debugging │
│ PendingInterceptorService → drives the global loading spinner │
│ HttpCacheAbstract → optional localStorage response cache │
└───────────┬──────────────────────────────────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────────────────┐
│ Backends (≈15 distinct base URLs — see integration-landscape) │
└──────────────────────────────────────────────────────────────────────┘
Orthogonal to all of the above:
Guards & resolvers route admission, supplier key resolution
Permissions (ngx-permissions) template and route gating
Feature flags build + global-settings + session layered toggles
Iframe bridge typed postMessage to/from EVA
Error pipeline ErrorHandler → parser → toast
How a screen loads
The supplier workspace is the canonical flow and exercises nearly every layer.
1. User navigates to /#/suppliers/<key>/general
2. Router admission, in the order declared on the route:
RouteAccessResolver → is there a user with a loginId and an unexpired JWT?
if not: remember the URL in sessionStorage, dispatch
Logout, redirect to /login
FeatureFlagsGeneralGuard → wait for auth state, then initialise feature flags
(build flags + global settings + session overrides)
NgxPermissionsGuard → evaluate the route's permission clause
SupplierKeyResolver → read :supplierKey from the parent route and dispatch
SetSupplierKey
3. @ngrx/router-store emits ROUTER_NAVIGATION.
SupplierEffects.navigateToSupplier walks the activated route tree for a
supplierKey param and fans out three actions:
SET_SUPPLIER_KEY → point the supplier slice at this supplier
SET_TARGET_UUID → point the CRM slice at the same key
FETCH_SUPPLIER_DETAILS → load the aggregate
4. SupplierEffects.getSupplierDetails checks the cache in state
(supplier.suppliers[key]); on a miss or an explicit refresh it forkJoins:
SupplierService.fetchSupplierDetails (internal API)
FundingParticularsService.fetchFundingParticulars
SupplierService.getSupplierBalance
LongTermContractsService.getContractsForSupplier + historical
For an ADVANCE_AUDITORS user the last three are replaced with of(null).
Result → UPDATE_SUPPLIER_MODEL.
5. AuthInterceptor stamps X-Session-Token on each request; a 401 dispatches
Logout with a user-visible message.
6. Components select from the store, render, and edit through
UPDATE_MODEL_PART actions keyed by formGroupKey.
7. In parallel, CRM effects load the timeline for the same target UUID, and the
layout reducer matches the URL against its route regexes to decide whether the
CRM panel and supplier info panel are open, fixed or hidden.
The same skeleton — guards, router-store, cache-checking effect, part-wise edit — recurs across the app. Read state management for the idioms and the suppliers domain for the business meaning.
Layer responsibilities
| Layer | Owns | Must not |
|---|---|---|
Components |
Rendering, local form state, dispatching actions, calling services for non-shared data. |
Contain business arithmetic that belongs in a service; construct URLs; read
|
Store (actions/reducers/effects) |
Shared cross-screen state, orchestration of multi-call loads, caching of already-fetched aggregates. |
Hold state that only one component reads; perform HTTP directly in a reducer. |
Services |
One backend concern each: build the request, map the response into a model, translate errors. |
Know about components; keep UI state; hardcode a base URL. |
Interceptors |
Session token, request recording, spinner, cache. |
Contain feature logic. |
Guards / resolvers |
Route admission and route-parameter plumbing. |
Fetch domain aggregates (that is an effect’s job — |
Models |
Shape and serialisation of API payloads, plus derived getters
( |
Perform I/O. |
Dependency rules that actually hold
-
Feature modules depend on
shared/, never the reverse. The one systematic exception isshared/code that reaches intosuppliers/modelfor the supplier types (for exampleEvaCommunicationServiceandLookupRepository) — a known wart, not a licence to add more. -
Every module may depend on
store/app.reducers.tsfor theAppStatetype. This makesAppStatea de-facto shared contract; changing a slice’s shape is a cross-module change. -
pyb-materialis a leaf: it only re-exports Angular Material. -
mocks/and*-mock.service.tsfiles are test-only seams and must not be referenced from production paths. -
Configuration flows one way:
environment.ts→AppConfigService→ services. A handful of services importenvironmentdirectly (EventsService,EvaService,LookupRepository,PendingReviewService,FeatureFlagsService); new code should takeAppConfigService.
Where the app is not layered
Three areas cut across the layering and are worth knowing before you change them:
- Supplier "model parts"
-
Editing a supplier is not a single form submit. Each card owns a
formGroupKeyand the store keeps three parallel maps (in-edit, saved, errors) keyed by it. The reducer merges saved values into bothSupplierDetailsModelandFundingParticularsModel. See partial editing. - CRM timeline
-
The CRM slice is a normalised store built by hand:
crmItemsis a uuid→item map and each stream (notes,tasks, …) is an ordered uuid list, with a per-target cache incrmStatesList. It also keeps request headers in sync by subscribing to the store insideCrmService. See CRM. - EVA iframes
-
Whole regions of the supplier workspace are rendered by another application. The seam is a
postMessageprotocol, not a component boundary, and state is synchronised explicitly in both directions. See iframe messaging and EVA.
Quality gates
| Gate | Enforcement |
|---|---|
Lint |
ESLint with |
Format |
Prettier — single quotes, semicolons, 2-space indent, |
Unit tests |
Karma + Jasmine, |
CI |
|
E2E |
Cypress 7.x under |
See testing for details.