Updates the architecture/dependency diagrams and omissions table to match what actually shipped: RemoteData<T> (not <E, T>), @angular/router moved from "avoided" to "used", the new shared/infrastructure/http.ts fetch boundary, and the CI/coverage gates now in place. Removes the now-false "CI pipeline" omission row and adds honest rows for the linter and per-environment config this template still deliberately skips. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ng-signals-template
A minimal Angular 22 starter: signals for state, resource() for async data, and a
RemoteData type that makes "loading but has an error," "success with no value," and
similar illegal combinations impossible to construct. One worked feature (users/) shows
the whole pattern end to end, including a routed, deep-linkable action (click a user →
see their details at /users/:id → go back).
It's extracted from a larger reference app — the "POC" referenced throughout this document — which shows the same ideas grown up to production scale (multi-context DDD, enforced architecture boundaries, i18n, a real design system, generated API clients). This template deliberately keeps only the part of that setup useful from day one, and documents exactly where to reach for the rest as a project grows.
Running it
npm start # ng serve
npm test # ng test (Vitest)
npm run test:coverage # ng test --coverage, gated by thresholds in angular.json
npm run format:check # prettier --check .
npm run build # production build
Architecture
Each business capability is a context (users/) split into
domain → application → infrastructure/ui layers; dependencies only point inward.
shared/ holds cross-context building blocks, itself layered by atomic-design tier
(atoms → molecules → templates). This is convention, not lint-enforced yet — see
Growth path.
flowchart TD
subgraph shared["shared/ (cross-context)"]
sapp["application/\nremote-data.ts"]
sinfra["infrastructure/\nhttp.ts (the only fetch() caller)"]
sui["ui/\natoms → molecules → templates"]
end
subgraph users["users/ (one context)"]
udom["domain/\nuser.ts (no Angular import)"]
uinfra["infrastructure/\nusers.adapter.ts (parses + type-guards)"]
uapp["application/\n*.resource.ts"]
uui["ui/\norganisms + page"]
end
uui --> uapp
uapp --> udom
uapp --> uinfra
uinfra -. uses .-> sinfra
uui -. reuses .-> sui
uapp -. reuses .-> sapp
classDef domain fill:#eef,stroke:#88a
class udom,uinfra,uapp,uui domain
Why so few dependencies
The whole app runs on @angular/core's signals + resource(), native fetch, and
@angular/router for navigation — nothing else is imported directly. rxjs is a peer
dependency of @angular/core itself and is never imported by this template's own code.
@angular/forms has been dropped entirely — nothing here used it.
flowchart LR
app["Your app code"]
app -->|imports| core["@angular/core\nsignal · computed · resource"]
app -->|calls| fetchApi["native fetch()"]
app -->|navigates via| router["@angular/router"]
app -.->|"peer dep of @angular/core,\nnever imported directly"| rxjs["rxjs"]
app -.->|"never installed —\nzoneless by default"| zonejs["zone.js"]
app -.->|"never installed"| ngrx["NgRx / any store lib"]
app -.->|"never installed —\nfetch() instead"| http["HttpClient"]
classDef used fill:#dfe,stroke:#4a4
classDef avoided fill:#fee,stroke:#a44,stroke-dasharray: 4 4
class core,fetchApi,router used
class rxjs,zonejs,ngrx,http avoided
What's here
-
RemoteData<T>(shared/application/remote-data.ts) — a 4-variant union (Loading | Empty | Failure | Success) plusfromResource(), which projects Angular's ownresource()into one.Failurecarries the realError— no generic error parameter to instantiate, since Angular'sresource()only ever fails with anError. No store, no reducer —resource()already holds the async state;RemoteDatajust normalizes it for exhaustive rendering. -
<app-async>(shared/ui/molecules/async.component.ts) — a@switchover all 4 states: a spinner while loading, an empty message, a failure message with a retry button, or your projected content on success. Because<ng-content>can't hand data back to its parent, pages narrow the sameRemoteDatavalue themselves with@letbefore rendering their payload — seeusers.page.tsfor the pattern. Reused by both fetches inusers/. -
<app-page-shell>(shared/ui/templates/page-shell.component.ts) — a heading plus one content slot. That's it. -
users/— one feature context, laid out the same way a bigger one would be:domain/(pure types, no Angular import),infrastructure/(parses and type-guards the raw API response intoUser/UserDetail, throwing a typedHttpErrororParseErroron failure —shared/infrastructure/http.tsis the only file that callsfetchdirectly),application/(composes infrastructure +resource()— this is also exactly where a real store would slot in later),ui/(organisms + the page). Routing is wired withwithComponentInputBinding(), so clicking a user navigates to/users/:idand the route param binds straight onto the page'suserIdinput — no manual signal wiring, and the detail view is deep-linkable:sequenceDiagram participant Page as UsersPage participant Res as usersResource() participant Api as fetchUsers (fetch) participant RD as fromResource() participant Async as app-async participant List as app-user-list participant Router as Router Page->>Res: usersResource() Res->>Api: loader() Api-->>Res: User[] Page->>RD: fromResource(usersResource, isEmptyUserList) RD-->>Async: RemoteData tag (Loading/Empty/Failure/Success) Async->>List: render on Success List->>Router: select.emit(id) → navigate(['/users', id]) Router->>Page: binds userId input from the route Note over Page: template swaps to app-user-detail,<br/>which repeats the same chain via userDetailResource -
Path aliases
@shared/*and@users/*(seetsconfig.json) instead of relative../../imports, one per context — add one per new context you create. -
Tests are BDD-style (
describe/it, oneexpectperit) and black-box: they assert on rendered DOM and emitted events, never on a component's private fields, so a test never breaks just because an internal was refactored. Routed pages are tested withRouterTestingHarnessdriving real navigation (seeusers.page.spec.ts), not by poking at signals directly. -
Type safety & CI —
strictandstrictTemplatesare both on, soRemoteData's "illegal states unrepresentable" claim is actually checked by the compiler, not just a convention..github/workflows/ci.ymlruns a format check, the test suite (with coverage thresholds), and a production build on every push and pull request.
What's deliberately not here (vs. the POC)
| Missing | Why |
|---|---|
Elm-style store (createStore/Model-Msg-reduce) |
Not needed until a page's state has more than a couple of interacting fields — see Growth path below. |
i18n ($localize + translation file) |
POC-specific requirement (a Dutch app shipping English too); irrelevant for a single-locale starter. |
| CIBG Huisstijl theming / token bridge | The POC's specific design system; a starter has no house style to vendor yet. |
dependency-cruiser boundary enforcement |
Real value once you have 2+ contexts that must not import each other; overhead for one. |
contracts/ layer + generated API client |
A hand-written parse* boundary already guards the response shape (see users/infrastructure/users.adapter.ts); only add codegen once there's a real OpenAPI contract to generate from, not a public test API. |
Linter (ESLint/angular-eslint) |
strict + strictTemplates + Prettier already catch most of what a linter would here; add one when you have team-specific rules — it's five more devDependencies and a config most teams rewrite anyway. |
Per-environment config (.env / fileReplacements) |
The API base URL is a single exported const in shared/infrastructure/http.ts — greppable, zero ceremony. Swap for Angular's built-in fileReplacements + src/environments/ when you need per-deploy values. |
| Storybook + axe a11y gate | Testing/documentation infrastructure that pays off at a much bigger component count. |
Growth path — when you outgrow this
Each of these is a real, working pattern in the POC — copy it when you actually need it, not before:
- A page's state grows past 2-3 interacting fields, or needs undo/multi-step flow →
add an Elm-style store:
shared/application/store.ts(createStore) + a*.machine.tsper feature (Model/Msg/purereduce). Seedocs/adding-a-store.mdfor a step-by-step walkthrough that builds one from scratch using only signals — no new dependencies. - You have 2+ contexts that must not import each other → add
dependency-cruiser(.dependency-cruiser.js) to enforce thedomain → application → infrastructure/uidirection this template already follows by convention but doesn't check. - You're consuming a real backend's OpenAPI contract → add a
contracts/layer (wire DTOs) + a generated typed client on top of the hand-writtenparse*boundary already ininfrastructure/(seeusers/infrastructure/users.adapter.ts, and ADR-0001 /.claude/skills/bff-endpoint/SKILL.mdif you're working from the POC directly). - A second locale → wrap user-facing copy in
$localizewith a stable custom id and add a translation.xlffile (see the POC'sCLAUDE.md"User-facing copy" convention). - A real design system → vendor your CSS, then bridge your own token names onto it the
way ADR-0003 (
docs/reference/architecture/0003-cibg-huisstijl.mdin the POC) does — keep your token names stable, only their values change. - Testing/a11y at real component count → add Storybook + the axe a11y addon so every component's states are visually verifiable and accessibility-checked, not just behavior-tested.
Folder convention
Each business capability is a context under src/app/<context>/, split into
domain/application/infrastructure/ui — dependencies point inward
(ui → application → domain; only application reaches infrastructure). Inside a
context's ui/, components are organized by atomic-design layer (atoms → molecules → organisms → templates → pages); shared/ holds only cross-context building blocks. When
you add a second context, mirror users/'s shape.