From a3ea91edd25320cfc19c687883ba251815ee6ceb Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Sat, 1 Aug 2026 08:56:20 +0200 Subject: [PATCH] docs: add architecture/dependency Mermaid diagrams and Elm-store guide 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. --- README.md | 83 +++++++++++++- docs/adding-a-store.md | 255 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 336 insertions(+), 2 deletions(-) create mode 100644 docs/adding-a-store.md diff --git a/README.md b/README.md index d56c9ce..4053892 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,63 @@ 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`** (`shared/application/remote-data.ts`) — a 4-variant union @@ -35,7 +92,27 @@ npm test # ng test (Vitest) 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. + `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,
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 @@ -61,7 +138,9 @@ 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`). + 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 diff --git a/docs/adding-a-store.md b/docs/adding-a-store.md new file mode 100644 index 0000000..35c279f --- /dev/null +++ b/docs/adding-a-store.md @@ -0,0 +1,255 @@ +# Adding an Elm-style store — a step-by-step guide + +This walks through building a small, Elm-like store for a page's state, using nothing +but `signal`/`computed` from `@angular/core`. No new dependency, no class hierarchy, no +FP background required — every term below is explained in plain words the first time +it's used. + +If you haven't read the main [README](../README.md) yet, read it first — this doc +assumes you know what `RemoteData` and `resource()` already do in this template, because +the store is **not** a replacement for either of those. + +## 1. When you actually need this + +Don't reach for this until you actually feel the pain. A single `signal` is simpler and +is exactly what this template already uses for the `users/` page's selection state: + +```ts +protected selectedUserId = signal(null); +``` + +That's fine as long as there's one thing changing. The store earns its keep once a +page's state has **3 or more fields that change together**, or needs an **undo / +multi-step flow** (a wizard, a form with a "back" step, anything with history). + +```mermaid +flowchart LR + q1{"Does one signal\nstill describe the state?"} + q1 -->|yes| plain["Keep the plain signal(s).\nNothing to build."] + q1 -->|no, 3+ fields\nchange together, or\nneeds undo/steps| store["Build a store\n(this guide)"] +``` + +## 2. The core idea, with no Angular involved yet + +Three plain ingredients, no framework required: + +- **State** — a plain object describing what's true right now. Example: `{ count: 0 }`. +- **A message** — a plain object describing *what happened*. Example: + `{ type: 'increment' }`. (Some call this an "action" — same thing.) +- **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 + output, and it never reaches outside itself (no HTTP calls, no mutating its inputs). + +```mermaid +flowchart LR + S["State\n{ count: 0 }"] --> R + M["Message\n{ type: 'increment' }"] --> R + R["reduce(state, msg)"] --> S2["New state\n{ count: 1 }"] +``` + +In plain TypeScript, before any Angular signal is involved: + +```ts +type CounterState = { count: number }; +type CounterMsg = { type: 'increment' } | { type: 'decrement' } | { type: 'reset' }; + +function reduce(state: CounterState, msg: CounterMsg): CounterState { + switch (msg.type) { + case 'increment': + return { count: state.count + 1 }; + case 'decrement': + return { count: state.count - 1 }; + case 'reset': + return { count: 0 }; + } +} + +reduce({ count: 0 }, { type: 'increment' }); // { count: 1 } — a plain function call +``` + +Nothing above needs Angular. That's the whole trick: the "store" is just this function +plus somewhere to remember the current state. + +## 3. Wiring it to a signal + +"Somewhere to remember the current state" is exactly what a `signal` already is. This is +the whole store — one small factory function, no class: + +`shared/application/store.ts`: + +```ts +import { signal } from '@angular/core'; + +export function createStore( + initialState: State, + reduce: (state: State, msg: Msg) => State, +) { + const state = signal(initialState); + + return { + state: state.asReadonly(), + dispatch: (msg: Msg) => state.update((current) => reduce(current, msg)), + }; +} +``` + +```mermaid +flowchart LR + Comp["component calls\ndispatch(msg)"] --> D["dispatch()"] + D --> R["reduce(state(), msg)"] + R --> Set["state.set(newState)"] + Set --> Sig["signal notifies\nany template reading state()"] +``` + +That's ~10 lines, zero new dependencies. `state` is exposed read-only (`asReadonly()`) so +only `dispatch` can ever change it — nothing outside the store mutates state directly, +which is what keeps `reduce` trustworthy. + +## 4. A `*.machine.ts` per feature + +Each feature gets its own file with just its `Model` (state shape), `Msg` (union of +things that can happen), and `reduce` — no Angular imports needed here either, so it's +trivially unit-testable. + +Example: extending the `users/` feature so selecting a user is remembered as part of a +richer flow (say, tracking recently viewed users too). + +`users/application/users.machine.ts`: + +```ts +export type UsersModel = { + selectedUserId: number | null; + recentlyViewed: number[]; +}; + +export type UsersMsg = + | { type: 'select'; id: number } + | { type: 'closeDetail' }; + +export const initialUsersModel: UsersModel = { + selectedUserId: null, + recentlyViewed: [], +}; + +export function reduceUsers(model: UsersModel, msg: UsersMsg): UsersModel { + switch (msg.type) { + case 'select': + return { + selectedUserId: msg.id, + recentlyViewed: [msg.id, ...model.recentlyViewed.filter((id) => id !== msg.id)], + }; + case 'closeDetail': + return { ...model, selectedUserId: null }; + } +} +``` + +This is the point where a single `signal` stopped being enough: +`recentlyViewed` is a second field that changes *together* with `selectedUserId`, and +`reduce` is the one place that keeps them in sync. + +## 5. Using it from a component + +```ts +import { Component } from '@angular/core'; +import { createStore } from '@shared/application/store'; +import { initialUsersModel, reduceUsers } from '@users/application/users.machine'; + +@Component({ + selector: 'app-users-page', + template: ` + @if (store.state().selectedUserId; as id) { + + } @else { + + } + `, +}) +export class UsersPage { + protected store = createStore(initialUsersModel, reduceUsers); +} +``` + +```mermaid +sequenceDiagram + participant Tpl as Template + participant Store as store + participant Reduce as reduceUsers() + + Tpl->>Store: dispatch({ type: 'select', id: 3 }) + Store->>Reduce: reduce(currentModel, msg) + Reduce-->>Store: newModel + Store->>Store: state.set(newModel) + Store-->>Tpl: state() signal updates, view re-renders +``` + +Compare this to the `signal` version in the current README: same event, +same template swap — the difference only shows up once there's a second field to keep +consistent. + +## 6. Async actions — the store doesn't replace `resource()` + +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` +calls into `reduce` — `reduce` must stay pure (no side effects), so a network call has no +business being inside one. + +```mermaid +flowchart TD + subgraph async["Async data (unchanged)"] + Fetch["fetchUsers()"] --> Res["resource()"] --> RD["fromResource()"] + end + subgraph sync["Synchronous UI state (the store)"] + Msg["dispatch(msg)"] --> Reduce["reduce()"] --> State["store.state()"] + end + RD -.->|"read via computed()\nif the store needs to react"| State +``` + +If the store genuinely needs to know about a resource's status, read it in a +`computed()` outside the store rather than smuggling it into `dispatch`: + +```ts +protected usersResource = usersResource(); +protected hasUsers = computed(() => this.usersResource.hasValue()); +``` + +## 7. Testing — this is the payoff + +`reduce` is a pure function, so testing it needs no Angular, no `TestBed`, no mocks — +just call it and check the result, the same black-box style already used for +`remote-data.spec.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { initialUsersModel, reduceUsers } from './users.machine'; + +describe('reduceUsers', () => { + it('sets selectedUserId and pushes it onto recentlyViewed on select', () => { + const next = reduceUsers(initialUsersModel, { type: 'select', id: 3 }); + expect(next).toEqual({ selectedUserId: 3, recentlyViewed: [3] }); + }); + + it('clears selectedUserId on closeDetail, keeps recentlyViewed', () => { + const withSelection = { selectedUserId: 3, recentlyViewed: [3] }; + const next = reduceUsers(withSelection, { type: 'closeDetail' }); + expect(next).toEqual({ selectedUserId: null, recentlyViewed: [3] }); + }); +}); +``` + +No component, no DOM, no async — just data in, data out. + +## Recap: the full cycle + +```mermaid +flowchart LR + View["View\n(template event)"] -->|dispatch(Msg)| Dispatch["store.dispatch"] + Dispatch --> Reduce["reduce(state, Msg)"] + Reduce -->|"new state"| Signal["state signal .set()"] + Signal -->|"state() read in template"| View +``` + +That loop — **view sends a message → `reduce` computes the next state → the signal +updates → the view re-renders** — is the entire pattern. Everything above it (multiple +`*.machine.ts` files, richer models, selectors) is the same loop repeated, not a new +concept.