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.
This commit is contained in:
@@ -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<E, T>`** (`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,<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
|
||||
@@ -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
|
||||
|
||||
@@ -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<number | null>(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<State, Msg>(
|
||||
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<number | null>` 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) {
|
||||
<app-user-detail [userId]="id" (close)="store.dispatch({ type: 'closeDetail' })" />
|
||||
} @else {
|
||||
<app-user-list (select)="store.dispatch({ type: 'select', id: $event })" ... />
|
||||
}
|
||||
`,
|
||||
})
|
||||
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<number | null>` 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.
|
||||
Reference in New Issue
Block a user