docs: bring README in line with strict mode, routing, and the HTTP boundary
ci / verify (push) Successful in 47s
ci / verify (push) Successful in 47s
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>
This commit is contained in:
@@ -3,8 +3,8 @@
|
|||||||
A minimal Angular 22 starter: signals for state, `resource()` for async data, and a
|
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
|
`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
|
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 →
|
the whole pattern end to end, including a routed, deep-linkable action (click a user →
|
||||||
go back).
|
see their details at `/users/:id` → go back).
|
||||||
|
|
||||||
It's extracted from a larger reference app — the "POC" referenced throughout this
|
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,
|
document — which shows the same ideas grown up to production scale (multi-context DDD,
|
||||||
@@ -15,8 +15,11 @@ exactly where to reach for the rest as a project grows.
|
|||||||
## Running it
|
## Running it
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm start # ng serve
|
npm start # ng serve
|
||||||
npm test # ng test (Vitest)
|
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
|
## Architecture
|
||||||
@@ -31,12 +34,13 @@ Growth path.
|
|||||||
flowchart TD
|
flowchart TD
|
||||||
subgraph shared["shared/ (cross-context)"]
|
subgraph shared["shared/ (cross-context)"]
|
||||||
sapp["application/\nremote-data.ts"]
|
sapp["application/\nremote-data.ts"]
|
||||||
|
sinfra["infrastructure/\nhttp.ts (the only fetch() caller)"]
|
||||||
sui["ui/\natoms → molecules → templates"]
|
sui["ui/\natoms → molecules → templates"]
|
||||||
end
|
end
|
||||||
|
|
||||||
subgraph users["users/ (one context)"]
|
subgraph users["users/ (one context)"]
|
||||||
udom["domain/\nuser.ts (no Angular import)"]
|
udom["domain/\nuser.ts (no Angular import)"]
|
||||||
uinfra["infrastructure/\nusers.adapter.ts (the only fetch() caller)"]
|
uinfra["infrastructure/\nusers.adapter.ts (parses + type-guards)"]
|
||||||
uapp["application/\n*.resource.ts"]
|
uapp["application/\n*.resource.ts"]
|
||||||
uui["ui/\norganisms + page"]
|
uui["ui/\norganisms + page"]
|
||||||
end
|
end
|
||||||
@@ -44,6 +48,7 @@ flowchart TD
|
|||||||
uui --> uapp
|
uui --> uapp
|
||||||
uapp --> udom
|
uapp --> udom
|
||||||
uapp --> uinfra
|
uapp --> uinfra
|
||||||
|
uinfra -. uses .-> sinfra
|
||||||
uui -. reuses .-> sui
|
uui -. reuses .-> sui
|
||||||
uapp -. reuses .-> sapp
|
uapp -. reuses .-> sapp
|
||||||
|
|
||||||
@@ -53,9 +58,10 @@ flowchart TD
|
|||||||
|
|
||||||
### Why so few dependencies
|
### Why so few dependencies
|
||||||
|
|
||||||
The whole app runs on `@angular/core`'s signals + `resource()` and native `fetch` —
|
The whole app runs on `@angular/core`'s signals + `resource()`, native `fetch`, and
|
||||||
nothing else is imported directly, even though a couple of these are installed
|
`@angular/router` for navigation — nothing else is imported directly. `rxjs` is a peer
|
||||||
transitively (by `@angular/forms`/`@angular/router`) or available and simply unused.
|
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.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
@@ -63,36 +69,44 @@ flowchart LR
|
|||||||
|
|
||||||
app -->|imports| core["@angular/core\nsignal · computed · resource"]
|
app -->|imports| core["@angular/core\nsignal · computed · resource"]
|
||||||
app -->|calls| fetchApi["native fetch()"]
|
app -->|calls| fetchApi["native fetch()"]
|
||||||
|
app -->|navigates via| router["@angular/router"]
|
||||||
|
|
||||||
app -.->|"installed transitively,\nnever imported directly"| rxjs["rxjs"]
|
app -.->|"peer dep of @angular/core,\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 —\nzoneless by default"| zonejs["zone.js"]
|
||||||
app -.->|"never installed"| ngrx["NgRx / any store lib"]
|
app -.->|"never installed"| ngrx["NgRx / any store lib"]
|
||||||
app -.->|"never installed —\nfetch() instead"| http["HttpClient"]
|
app -.->|"never installed —\nfetch() instead"| http["HttpClient"]
|
||||||
|
|
||||||
classDef used fill:#dfe,stroke:#4a4
|
classDef used fill:#dfe,stroke:#4a4
|
||||||
classDef avoided fill:#fee,stroke:#a44,stroke-dasharray: 4 4
|
classDef avoided fill:#fee,stroke:#a44,stroke-dasharray: 4 4
|
||||||
class core,fetchApi used
|
class core,fetchApi,router used
|
||||||
class rxjs,router,zonejs,ngrx,http avoided
|
class rxjs,zonejs,ngrx,http avoided
|
||||||
```
|
```
|
||||||
|
|
||||||
## What's here
|
## What's here
|
||||||
|
|
||||||
- **`RemoteData<E, T>`** (`shared/application/remote-data.ts`) — a 4-variant union
|
- **`RemoteData<T>`** (`shared/application/remote-data.ts`) — a 4-variant union
|
||||||
(`Loading | Empty | Failure | Success`) plus `fromResource()`, which projects Angular's
|
(`Loading | Empty | Failure | Success`) plus `fromResource()`, which projects Angular's
|
||||||
own `resource()` into one. No store, no reducer — `resource()` already holds the async
|
own `resource()` into one. `Failure` carries the real `Error` — no generic error
|
||||||
state; `RemoteData` just normalizes it for exhaustive rendering.
|
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
|
- **`<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
|
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/`.
|
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
|
- **`<app-page-shell>`** (`shared/ui/templates/page-shell.component.ts`) — a heading plus
|
||||||
one content slot. That's it.
|
one content slot. That's it.
|
||||||
- **`users/`** — one feature context, laid out the same way a bigger one would be:
|
- **`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
|
`domain/` (pure types, no Angular import), `infrastructure/` (parses and type-guards
|
||||||
call `fetch`), `application/` (composes infrastructure + `resource()` — this is also
|
the raw API response into `User`/`UserDetail`, throwing a typed `HttpError` or
|
||||||
exactly where a real store would slot in later), `ui/` (organisms + the page). Clicking
|
`ParseError` on failure — `shared/infrastructure/http.ts` is the only file that calls
|
||||||
a user in the list sets one plain `signal` on the page, which swaps in a
|
`fetch` directly), `application/` (composes infrastructure + `resource()` — this is
|
||||||
`UserDetailComponent` that does its own independent fetch:
|
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:
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
@@ -102,6 +116,7 @@ flowchart LR
|
|||||||
participant RD as fromResource()
|
participant RD as fromResource()
|
||||||
participant Async as app-async
|
participant Async as app-async
|
||||||
participant List as app-user-list
|
participant List as app-user-list
|
||||||
|
participant Router as Router
|
||||||
|
|
||||||
Page->>Res: usersResource()
|
Page->>Res: usersResource()
|
||||||
Res->>Api: loader()
|
Res->>Api: loader()
|
||||||
@@ -109,27 +124,35 @@ flowchart LR
|
|||||||
Page->>RD: fromResource(usersResource, isEmptyUserList)
|
Page->>RD: fromResource(usersResource, isEmptyUserList)
|
||||||
RD-->>Async: RemoteData tag (Loading/Empty/Failure/Success)
|
RD-->>Async: RemoteData tag (Loading/Empty/Failure/Success)
|
||||||
Async->>List: render on Success
|
Async->>List: render on Success
|
||||||
List->>Page: select.emit(id)
|
List->>Router: select.emit(id) → navigate(['/users', id])
|
||||||
Page->>Page: selectedUserId.set(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
|
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
|
- Path aliases `@shared/*` and `@users/*` (see `tsconfig.json`) instead of relative
|
||||||
`../../` imports, one per context — add one per new context you create.
|
`../../` 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
|
- 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
|
on rendered DOM and emitted events, never on a component's private fields, so a test
|
||||||
never breaks just because an internal was refactored.
|
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 & CI** — `strict` 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)
|
## What's deliberately not here (vs. the POC)
|
||||||
|
|
||||||
| Missing | Why |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| `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. |
|
| `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. |
|
||||||
| Storybook + axe a11y gate | Testing/documentation infrastructure that pays off at a much bigger component count. |
|
| 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. |
|
||||||
| CI pipeline | Nothing to gate yet with one context and no deploy target. |
|
| 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
|
## Growth path — when you outgrow this
|
||||||
|
|
||||||
@@ -146,9 +169,10 @@ not before:
|
|||||||
infrastructure`/`ui` direction this template already follows by convention but doesn't
|
infrastructure`/`ui` direction this template already follows by convention but doesn't
|
||||||
check.
|
check.
|
||||||
- **You're consuming a real backend's OpenAPI contract** → add a `contracts/` layer
|
- **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
|
(wire DTOs) + a generated typed client on top of the hand-written `parse*` boundary
|
||||||
`infrastructure/` (see ADR-0001, `.claude/skills/bff-endpoint/SKILL.md` if you're
|
already in `infrastructure/` (see `users/infrastructure/users.adapter.ts`, and
|
||||||
working from the POC directly).
|
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
|
- **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).
|
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
|
- **A real design system** → vendor your CSS, then bridge your own token names onto it the
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ flowchart LR
|
|||||||
Three plain ingredients, no framework required:
|
Three plain ingredients, no framework required:
|
||||||
|
|
||||||
- **State** — a plain object describing what's true right now. Example: `{ count: 0 }`.
|
- **State** — a plain object describing what's true right now. Example: `{ count: 0 }`.
|
||||||
- **A message** — a plain object describing *what happened*. Example:
|
- **A message** — a plain object describing _what happened_. Example:
|
||||||
`{ type: 'increment' }`. (Some call this an "action" — same thing.)
|
`{ type: 'increment' }`. (Some call this an "action" — same thing.)
|
||||||
- **A pure update function** — a function that takes the current state and a message,
|
- **A pure update function** — a function that takes the current state and a message,
|
||||||
and returns the **new** state. "Pure" just means: same inputs always give the same
|
and returns the **new** state. "Pure" just means: same inputs always give the same
|
||||||
@@ -122,9 +122,7 @@ export type UsersModel = {
|
|||||||
recentlyViewed: number[];
|
recentlyViewed: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UsersMsg =
|
export type UsersMsg = { type: 'select'; id: number } | { type: 'closeDetail' };
|
||||||
| { type: 'select'; id: number }
|
|
||||||
| { type: 'closeDetail' };
|
|
||||||
|
|
||||||
export const initialUsersModel: UsersModel = {
|
export const initialUsersModel: UsersModel = {
|
||||||
selectedUserId: null,
|
selectedUserId: null,
|
||||||
@@ -145,7 +143,7 @@ export function reduceUsers(model: UsersModel, msg: UsersMsg): UsersModel {
|
|||||||
```
|
```
|
||||||
|
|
||||||
This is the point where a single `signal<number | null>` stopped being enough:
|
This is the point where a single `signal<number | null>` stopped being enough:
|
||||||
`recentlyViewed` is a second field that changes *together* with `selectedUserId`, and
|
`recentlyViewed` is a second field that changes _together_ with `selectedUserId`, and
|
||||||
`reduce` is the one place that keeps them in sync.
|
`reduce` is the one place that keeps them in sync.
|
||||||
|
|
||||||
## 5. Using it from a component
|
## 5. Using it from a component
|
||||||
@@ -189,7 +187,7 @@ consistent.
|
|||||||
|
|
||||||
## 6. Async actions — the store doesn't replace `resource()`
|
## 6. Async actions — the store doesn't replace `resource()`
|
||||||
|
|
||||||
Keep these concerns separate: `resource()` still owns *fetching*; the store only owns
|
Keep these concerns separate: `resource()` still owns _fetching_; the store only owns
|
||||||
synchronous state that's derived from, or reacts to, what's fetched. Don't move `fetch`
|
synchronous state that's derived from, or reacts to, what's fetched. Don't move `fetch`
|
||||||
calls into `reduce` — `reduce` must stay pure (no side effects), so a network call has no
|
calls into `reduce` — `reduce` must stay pure (no side effects), so a network call has no
|
||||||
business being inside one.
|
business being inside one.
|
||||||
|
|||||||
Reference in New Issue
Block a user