Skip to content

Project Layout

Daniel Hokanson edited this page Aug 30, 2026 · 1 revision

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.

The three buckets

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.

Standalone, signals, zoneless

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 bare Promise.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 in angular.json set changeDetection: OnPush on 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 / @for are the only form used; *ngIf and *ngFor are 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/ is the component library in all but packaging

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 in app.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; ngModel is out.
  • <app-validation-button> — wraps a disabled submit and explains why it is disabled, instead of mat-error and inline validation.
  • <app-page-layout> / <app-page-header> — page scaffolding, so spacing and header treatment do not drift per feature.
  • The cap / capNot and role / roleNot structural directives, plus capabilityGuard and roleGuard in shared/guards/ — the client-side half of Capability Gating and Access and Roles. They hide doors; they do not defend them.
  • The terminology pipe — 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.

How close shared/ is to being separable

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.

Clone this wiki locally