README now shows folder layering, minimal-Angular dependency flow, and the users feature data flow as diagrams. Adds docs/adding-a-store.md, a step-by-step guide to building an Elm-style store from just signals.
169 lines
8.7 KiB
Markdown
169 lines
8.7 KiB
Markdown
# 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 an action (click a user → see their details →
|
|
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
|
|
|
|
```bash
|
|
npm start # ng serve
|
|
npm test # ng test (Vitest)
|
|
```
|
|
|
|
## 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.
|
|
|
|
```mermaid
|
|
flowchart TD
|
|
subgraph shared["shared/ (cross-context)"]
|
|
sapp["application/\nremote-data.ts"]
|
|
sui["ui/\natoms → molecules → templates"]
|
|
end
|
|
|
|
subgraph users["users/ (one context)"]
|
|
udom["domain/\nuser.ts (no Angular import)"]
|
|
uinfra["infrastructure/\nusers.adapter.ts (the only fetch() caller)"]
|
|
uapp["application/\n*.resource.ts"]
|
|
uui["ui/\norganisms + page"]
|
|
end
|
|
|
|
uui --> uapp
|
|
uapp --> udom
|
|
uapp --> uinfra
|
|
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()` and native `fetch` —
|
|
nothing else is imported directly, even though a couple of these are installed
|
|
transitively (by `@angular/forms`/`@angular/router`) or available and simply unused.
|
|
|
|
```mermaid
|
|
flowchart LR
|
|
app["Your app code"]
|
|
|
|
app -->|imports| core["@angular/core\nsignal · computed · resource"]
|
|
app -->|calls| fetchApi["native fetch()"]
|
|
|
|
app -.->|"installed transitively,\nnever imported directly"| rxjs["rxjs"]
|
|
app -.->|"installed,\nnever used — signal swap instead"| router["@angular/router"]
|
|
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 used
|
|
class rxjs,router,zonejs,ngrx,http avoided
|
|
```
|
|
|
|
## What's here
|
|
|
|
- **`RemoteData<E, T>`** (`shared/application/remote-data.ts`) — a 4-variant union
|
|
(`Loading | Empty | Failure | Success`) plus `fromResource()`, which projects Angular's
|
|
own `resource()` into one. 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. 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/` (the only file allowed to
|
|
call `fetch`), `application/` (composes infrastructure + `resource()` — this is also
|
|
exactly where a real store would slot in later), `ui/` (organisms + the page). Clicking
|
|
a user in the list sets one plain `signal` on the page, which swaps in a
|
|
`UserDetailComponent` that does its own independent fetch:
|
|
|
|
```mermaid
|
|
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
|
|
|
|
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->>Page: select.emit(id)
|
|
Page->>Page: selectedUserId.set(id)
|
|
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.
|
|
|
|
## 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 + `parse*` boundary | Only earns its keep once you're consuming a real backend's OpenAPI contract, not a public test API. |
|
|
| Storybook + axe a11y gate | Testing/documentation infrastructure that pays off at a much bigger component count. |
|
|
| CI pipeline | Nothing to gate yet with one context and no deploy target. |
|
|
|
|
## 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`](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 + a hand-written `parse*` boundary in
|
|
`infrastructure/` (see 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.
|