-
Notifications
You must be signed in to change notification settings - Fork 0
Project Layout
The application is one Angular project with no libraries, no monorepo tooling and no NgModules. Everything under src/app sorts into three buckets — core/ for the shell chrome, shared/ for the cross-cutting building blocks, and features/ for one directory per functional area — and the interesting rule is the direction of the arrows between them. This page is about that layout and the posture it enforces.
| Directory | Holds | Rule |
|---|---|---|
src/app/core/layout/ |
The employee shell: header and drill-down sidebar | The only place the desktop chrome is defined |
src/app/shared/ |
components/, directives/, pipes/, guards/, interceptors/, services/, models/, validators/, utils/, errors/, tours/, capability/
|
May be imported from anywhere |
src/app/features/<area>/ |
Pages, components, services, models and a *.routes.ts per area |
Imports downward into shared/, never sideways into another feature |
Around them: src/styles/ (design tokens, mixins, reset, shared rules — SCSS only, no component code), src/environments/ (one file per build configuration), src/testing/ (unit-test doubles), src/types/ (ambient declarations for untyped dependencies), and public/ for static assets served as-is, including the translation catalogs.
Two conventions shape every file you will open. One object per file — one component, service, pipe, directive, guard, interceptor or model, never two. No barrel files: there is no index.ts anywhere, so every import names the real path. Both are enforced by review rather than by a script, but the second one has a happy side effect described at the bottom of this page.
A feature is lazy: app.routes.ts reaches each area through loadChildren: () => import('./features/<area>/<area>.routes'). Adding a feature therefore means adding one route entry and one routes file, not touching a module graph. The routing tree itself — which paths are guarded by what, and what the non-employee surfaces do — is The Surfaces.
There are no NgModule declarations in the codebase at all. Every component, directive and pipe is standalone and declares its own imports; providers are configured functionally in src/app/app.config.ts, which is where the router, HTTP client and its interceptor chain, translation, charts, markdown, the service worker and two app initializers are assembled.
Change detection is zoneless. angular.json configures no polyfills entry, so zone.js is not in the bundle, and nothing calls provideZoneChangeDetection. That is the whole configuration — zoneless is what you get by not asking for zones. The practical consequences:
-
Nothing schedules change detection for you. A value that the template reads must be a signal, or set inside something Angular already knows about.
setTimeout, a barePromise.then, a third-party library callback and a manually managed WebSocket handler will all update a plain property without the view noticing. -
All but a handful of components are
OnPush— the Angular schematics inangular.jsonsetchangeDetection: OnPushon generation, so new components inherit it rather than opting in. -
Constructor injection is not used anywhere. Every dependency comes from
inject(), which is what makes functional guards, functional interceptors and injection inside field initializers work uniformly. -
Function calls in template bindings are out, because the cheap "it re-evaluates anyway" assumption is gone. Derived values are
computed(). -
The template control-flow blocks
@if/@forare the only form used;*ngIfand*ngForare lint errors.
Signals are not decoration here — the signal API (signal, computed, input, output, effect, viewChild, and toSignal at the RxJS boundary) appears a couple of thousand times across the app. RxJS is still very much present for HTTP and SignalR, and the rule that keeps the two from leaking is every long-lived .subscribe() carries takeUntilDestroyed(this.destroyRef); the exemption is a fire-and-forget request that completes on its own. Router events, valueChanges and intervals are the three that bite.
shared/components/ is large — the whole design system lives there — and feature code is expected to reach for it rather than roll its own. The pieces that behave as stereotypes, meaning there is exactly one correct way to do the thing:
-
<app-dialog>— every dialog shell. Custom dialog chrome is a standards violation, and app-wide dialog defaults (backdrop click and ESC do not close) are set once inapp.config.ts. -
<app-data-table>— entity lists. A raw<table>in a feature is ratcheted debt, not a choice. - The form wrappers (
input,select,textarea,datepicker,autocomplete,currency-input,entity-picker, …) — a raw<input>,<select>or<textarea>in a feature template is likewise ratcheted. Forms are reactive;ngModelis out. -
<app-validation-button>— wraps a disabled submit and explains why it is disabled, instead ofmat-errorand inline validation. -
<app-page-layout>/<app-page-header>— page scaffolding, so spacing and header treatment do not drift per feature. - The
cap/capNotandrole/roleNotstructural directives, pluscapabilityGuardandroleGuardinshared/guards/— the client-side half of Capability Gating and Access and Roles. They hide doors; they do not defend them. - The
terminologypipe — the runtime label-override layer described on Customizing an Install. It is a different mechanism from i18n and the two are easy to confuse: translation catalogs are build assets, terminology overrides are database rows.
shared/interceptors/ is the other half of shared/'s weight, and its ordering in app.config.ts is load-bearing rather than stylistic — the demo and portal interceptors run before auth, the capability gate runs before the error interceptor so a short-circuited request never enters the error pipeline, and apiBaseInterceptor is registered last so every other interceptor keeps seeing familiar relative /api/v1/... URLs while the native shell rewrites them onto the enrolled instance's origin.
Close. The dependency direction is almost clean: shared/ is imported by everything and imports back into features/ in only a handful of places. Every one of them is a small, nameable inversion rather than a structural entanglement:
| What reaches back | Into | Why it is fixable |
|---|---|---|
shared/services/accounting.service.ts (and its spec) |
four accounting model files under features/admin/models/
|
Pure interfaces. They belong in shared/models/. |
shared/services/chat-hub.service.ts, chat-notification.service.ts, components/chat-preview-popup/
|
features/chat/models/chat-message-event.model |
One event interface, same fix. |
shared/models/timer-event.model.ts |
features/time-tracking/models/time-entry.model |
Same. |
shared/components/sankey-chart/ |
features/reports/models/sankey-flow-item.model |
Same. |
shared/components/status-timeline/ |
features/admin/services/admin.service |
A real service dependency — needs an injection token or an input, not a move. |
shared/components/onboarding-banner/ |
features/account and features/onboarding services |
Same shape as the previous row. |
So the work to lift shared/ into its own package is: move six model files down, and invert two component-to-service dependencies. The no-barrel-file rule helps here — because nothing imports through an index.ts, the true dependency edges are visible to grep and no re-export is quietly bundling half the app into a "shared" entry point.
Whether that extraction is worth doing is undecided, and nothing in the build is arranged for it today: there is one tsconfig.app.json, one project in angular.json, and no path aliases. Treat the near-cleanliness as a property worth not breaking rather than as a plan. The concrete rule that keeps it true: a new file in shared/ must not import from features/. If you need a type from a feature, move the type.
forge-ui · Apache 2.0 · built by Armory Works — this wiki covers the frontend; product-level truth lives on the Forge wiki.
Peer repos