Files
ng-signals-template/README.md
T
ehoandClaude Sonnet 5 7dddf875a2
ci / verify (push) Successful in 47s
docs: bring README in line with strict mode, routing, and the HTTP boundary
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>
2026-08-01 10:30:02 +02:00

11 KiB

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) plus fromResource(), which projects Angular's own resource() into one. Failure carries the real Error — no generic error parameter to instantiate, since Angular's resource() only ever fails with an Error. No store, no reducer — resource() already holds the async state; RemoteData just normalizes it for exhaustive rendering.

  • <app-async> (shared/ui/molecules/async.component.ts) — a @switch over 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 same RemoteData value themselves with @let before rendering their payload — see users.page.ts for the pattern. Reused by both fetches in users/.

  • <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 into User/UserDetail, throwing a typed HttpError or ParseError on failure — shared/infrastructure/http.ts is the only file that calls fetch directly), application/ (composes infrastructure + resource() — this is also exactly where a real store would slot in later), ui/ (organisms + the page). Routing is wired with withComponentInputBinding(), so clicking a user navigates to /users/:id and the route param binds straight onto the page's userId input — 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/* (see tsconfig.json) instead of relative ../../ imports, one per context — add one per new context you create.

  • Tests are BDD-style (describe/it, one expect per it) 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 with RouterTestingHarness driving real navigation (see users.page.spec.ts), not by poking at signals directly.

  • Type safety & CIstrict and strictTemplates are both on, so RemoteData's "illegal states unrepresentable" claim is actually checked by the compiler, not just a convention. .github/workflows/ci.yml runs 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.ts per feature (Model/Msg/pure reduce). See docs/adding-a-store.md for 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 the domain → application → infrastructure/ui direction 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-written parse* boundary already in infrastructure/ (see users/infrastructure/users.adapter.ts, and ADR-0001 / .claude/skills/bff-endpoint/SKILL.md if you're working from the POC directly).
  • A second locale → wrap user-facing copy in $localize with a stable custom id and add a translation .xlf file (see the POC's CLAUDE.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.md in 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.