|
| 1 | +--- |
| 2 | +title: 'The object graph they took from you' |
| 3 | +description: Frontend never proved object graphs wrong — it fled two mechanical defects, then canonized the flight as philosophy. Fix the defects and workspaceSet.active.editor.selectLine() comes home. |
| 4 | +date: 2026-07 |
| 5 | +--- |
| 6 | + |
| 7 | +# The object graph they took from you |
| 8 | + |
| 9 | + |
| 10 | + |
| 11 | +Read this expression slowly: |
| 12 | + |
| 13 | +```ts |
| 14 | +workspaceSet.active.editor.selectLine(42); |
| 15 | +``` |
| 16 | + |
| 17 | +It reads like the domain because it *is* the domain: a set of |
| 18 | +workspaces, the active one, its editor, an action. No store lookup, no |
| 19 | +ID passed to a selector, no hook ceremony. Every hop is a plain |
| 20 | +property; the method at the end arrives already bound. |
| 21 | + |
| 22 | +Most frontend codebases cannot write that sentence. Not because |
| 23 | +object graphs were proven wrong — nobody ever proved that — but |
| 24 | +because two mechanical defects made them unreliable, and the ecosystem |
| 25 | +fled. Then it did something stranger: it canonized the flight as |
| 26 | +philosophy. |
| 27 | + |
| 28 | +## The two defects |
| 29 | + |
| 30 | +**Defect one: module-cycle initialization order.** Domain models are |
| 31 | +cyclic by nature. A workspace holds editors; an editor knows its |
| 32 | +workspace. Put those classes in two files — where they belong — and |
| 33 | +the imports form a cycle. In JavaScript's module system, a class |
| 34 | +binding read before its module finishes evaluating throws |
| 35 | +`Cannot access 'X' before initialization`. Whether that happens |
| 36 | +depends on **load order**, which depends on entry points and bundler |
| 37 | +whims. The graph worked on Tuesday and crashed after a refactor moved |
| 38 | +an import line. |
| 39 | + |
| 40 | +**Defect two: unbound methods.** Detach a method from its object — |
| 41 | +hand it to an event listener, a callback, an array of handlers — and |
| 42 | +`this` falls off. Every team patched it per-site: arrow-function |
| 43 | +fields here, `.bind(this)` there, a wrapper closure somewhere else. |
| 44 | +Every patch was a small tax, and forgetting one was a runtime bug. |
| 45 | + |
| 46 | +Neither defect was ever fixed. So the ecosystem routed around them. |
| 47 | + |
| 48 | +## The flight |
| 49 | + |
| 50 | +Each workaround was locally reasonable: |
| 51 | + |
| 52 | +- **Flatten the model into stores.** No cross-references between |
| 53 | + classes if there are no classes — just one bag of state per domain |
| 54 | + noun. |
| 55 | +- **Pass IDs instead of references.** `editor.workspace` is a cycle |
| 56 | + risk; `editor.workspaceId` plus a lookup is "safe." |
| 57 | +- **Select instead of navigate.** If state is flat, reaching anything |
| 58 | + means a selector: find the active workspace, join it to its editor, |
| 59 | + memoize the join. |
| 60 | +- **Atomize logic into closure factories.** A function scope can't |
| 61 | + have a `this` problem if there is no `this`. |
| 62 | + |
| 63 | +Stack the workarounds and you get the architecture a generation |
| 64 | +learned as *best practice*: state normalized like a database, logic in |
| 65 | +composable functions, entities referenced by ID, every relationship |
| 66 | +reassembled at read time by selector code that exists only because the |
| 67 | +references were confiscated. The industry name for the result is the |
| 68 | +**anemic domain model** — data in one flat bag, behavior in another — |
| 69 | +and in frontend it stopped being an anti-pattern and became the |
| 70 | +default. Not because anyone chose it on the merits. Because hop two of |
| 71 | +a real object graph could throw depending on import order. |
| 72 | + |
| 73 | +## The homecoming |
| 74 | + |
| 75 | +Both defects die by construction in ivue's |
| 76 | +[namespace pattern](/guide/modules): |
| 77 | + |
| 78 | +- **Cycles never manifest** because every cross-module reference is |
| 79 | + late-bound — getter bodies, method bodies, first access. The import |
| 80 | + graph can contain cycles; nothing reads another module's value while |
| 81 | + modules are still initializing. The property is **per-edge**, so |
| 82 | + depth is unlimited: hop five is exactly as safe as hop one, because |
| 83 | + each dereference resolves at call time. |
| 84 | +- **Methods arrive bound** because the engine lazily binds them to the |
| 85 | + raw instance with stable identity — `instance.method` is the same |
| 86 | + function every read, safe to detach, hand to a listener, keep in a |
| 87 | + registry. |
| 88 | + |
| 89 | +And reactivity rides the same hops. State is `ref()` at the leaves, |
| 90 | +`.value` at the reads — and tracking follows whatever property path |
| 91 | +the read takes, at any depth: |
| 92 | + |
| 93 | +```ts |
| 94 | +// WorkspaceSet.ts |
| 95 | +import { Reactive } from 'ivue'; |
| 96 | +import { shallowRef } from 'vue'; |
| 97 | +import { Workspace } from './Workspace'; |
| 98 | + |
| 99 | +class $WorkspaceSet { |
| 100 | + get workspaces() { |
| 101 | + return shallowRef<Workspace.Model[]>([]); |
| 102 | + } |
| 103 | + get activeIndex() { |
| 104 | + return shallowRef(0); |
| 105 | + } |
| 106 | + |
| 107 | + // a plain getter — the graph hop IS the derivation |
| 108 | + get active() { |
| 109 | + return this.workspaces.value[this.activeIndex.value]; |
| 110 | + } |
| 111 | +} |
| 112 | + |
| 113 | +export namespace WorkspaceSet { |
| 114 | + export const $Class = $WorkspaceSet; // raw — children `extends` this |
| 115 | + export let Class = Reactive($Class); // reactive — you `new` this |
| 116 | + // the type of every unwrapping surface (defineExpose, reactive()) |
| 117 | + export type Instance = typeof Class.Instance; |
| 118 | +} |
| 119 | +``` |
| 120 | + |
| 121 | +A watcher that reads `workspaceSet.active.editor.cursorLine.value` |
| 122 | +subscribes to the active index, the workspace list, and the cursor — |
| 123 | +the whole path — because the reads happened inside its effect. Switch |
| 124 | +workspaces and it re-fires; no selector was written, no subscription |
| 125 | +managed. Two primitives, any depth. Much of what ships under the name |
| 126 | +"state management" is scaffolding around the missing graph. |
| 127 | + |
| 128 | +## Proof at depth |
| 129 | + |
| 130 | +[Invar](https://github.com/infinite-system/tui-editor), the terminal |
| 131 | +editor [built by AI agents on ivue](/blog/agents-built-an-editor), |
| 132 | +navigates its domain exactly this way. These are production lines from |
| 133 | +its composition root, quoted verbatim: |
| 134 | + |
| 135 | +```ts |
| 136 | +// RootView.ts — a reactive leaf read, four hops deep |
| 137 | +editorArea.title = workspaceSet.active.editor.hasDocument.value |
| 138 | + ? workspaceSet.active.editor.title |
| 139 | + : 'Editor'; |
| 140 | + |
| 141 | +// RootView.ts — five hops, crossing TWO namespaces in one expression |
| 142 | +languageForActive: () => |
| 143 | + LanguageRegistry.Class.forPath(workspaceSet.active.editor.document.path), |
| 144 | +``` |
| 145 | + |
| 146 | +Look at what each line takes for granted. The first reads a `ref` leaf |
| 147 | +(`hasDocument.value`) through three graph hops and a plain-getter |
| 148 | +derivation (`title`) beside it — inside a paint effect, so the title |
| 149 | +re-renders when the active workspace changes, the editor swaps, *or* |
| 150 | +the document opens. Nobody wrote a selector for that; the reads are |
| 151 | +the subscription. The second navigates the workspace graph AND |
| 152 | +dereferences a second module's replaceable |
| 153 | +[`Static()`](/guide/modules) seam — `LanguageRegistry.Class` — in the |
| 154 | +same expression. Under eager binding, that line is a load-order |
| 155 | +lottery ticket. Late-bound, it is just a sentence. |
| 156 | + |
| 157 | +They are two of **75 multi-hop call sites** counted across the |
| 158 | +editor's 153 files (grep, July 2026), written across 292 commits by |
| 159 | +authors who were never once bitten by initialization order. The fear |
| 160 | +was about the defects. It was never about the shape. |
| 161 | + |
| 162 | +## What stays true |
| 163 | + |
| 164 | +The boundary, stated plainly, because a claim this shaped earns its |
| 165 | +keep by what it rules out: |
| 166 | + |
| 167 | +- **Circular `extends` stays impossible** — a class can't be its own |
| 168 | + ancestor. That is logic, not a limitation; no pattern restores it. |
| 169 | +- **Eager top-level reads stay dangerous** — `new B.Class()` at module |
| 170 | + scope executes at load time, inside the danger window. The |
| 171 | + convention works because it makes every reference *late*; it does |
| 172 | + not bless eager ones. |
| 173 | +- **Normalization keeps its real jobs.** Deduplicating a server cache |
| 174 | + and syncing entities by ID across a wire are storage problems, and |
| 175 | + ID-keyed maps solve them well. The correction is narrower: your |
| 176 | + *runtime domain model* — the thing your logic navigates — no longer |
| 177 | + has to be flattened in fear. |
| 178 | + |
| 179 | +## The triptych |
| 180 | + |
| 181 | +This is the third confiscation ivue reverses, and the corrections |
| 182 | +compose. [Inheritance was exiled](/blog/inheritance-exile) for the |
| 183 | +sins of its abusers — the philosophy correction. |
| 184 | +[Objects were rented](/blog/rented-objects) from framework lifetimes — |
| 185 | +the lifetime correction. And the graph itself was taken — the |
| 186 | +composition correction. Put them back together and you get the thing |
| 187 | +frontend quietly stopped believing it deserved: a rich, reactive, |
| 188 | +navigable domain model, in plain classes, at any depth. |
| 189 | + |
| 190 | +`workspaceSet.active.editor.selectLine(42)` — welcome home. |
0 commit comments