Skip to content

Commit 1395ce4

Browse files
blog: The object graph they took from you
The composition correction, third panel of the triptych after inheritance-exile (philosophy) and rented-objects (lifetime): frontend never proved object graphs wrong — it fled two mechanical defects (module-cycle initialization order, unbound methods), then canonized the flight as best practice. The anemic domain model — flat stores, IDs instead of references, selector ceremony — was fear of hop two, not a design conclusion. The post walks both defects, the flight, the by-construction fixes (per-edge late binding = unlimited depth; engine-bound methods), and reactivity riding the same hops (ref() at leaves, .value at reads, no selectors). Proof at depth: 75 multi-hop call sites like workspaceSet.active.stageSelected() counted in Invar (grep, July 2026). Boundary section keeps it honest: circular extends stays logically impossible, eager top-level reads stay excluded, normalization keeps its real storage jobs. Banner via the blog-banner skill pipeline: crossed-out workarounds over the glowing chain workspaceSet.active.editor.selectLine(42). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 10ffb5a commit 1395ce4

4 files changed

Lines changed: 282 additions & 0 deletions

File tree

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
<style>
6+
* { margin: 0; padding: 0; box-sizing: border-box; }
7+
html, body { width: 1200px; height: 630px; overflow: hidden; }
8+
body {
9+
background:
10+
radial-gradient(1000px 560px at 50% -20%, rgba(99, 102, 241, 0.25), transparent 60%),
11+
radial-gradient(900px 560px at 50% 130%, rgba(34, 211, 238, 0.14), transparent 55%),
12+
#050a18;
13+
font-family: 'Geist Mono', 'DejaVu Sans Mono', monospace;
14+
position: relative;
15+
}
16+
.grid {
17+
position: absolute; inset: 0;
18+
background-image:
19+
linear-gradient(rgba(59, 130, 246, 0.06) 1px, transparent 1px),
20+
linear-gradient(90deg, rgba(59, 130, 246, 0.06) 1px, transparent 1px);
21+
background-size: 48px 48px;
22+
mask-image: radial-gradient(760px 480px at 50% 50%, black 25%, transparent 75%);
23+
-webkit-mask-image: radial-gradient(760px 480px at 50% 50%, black 25%, transparent 75%);
24+
}
25+
.old {
26+
position: absolute; top: 92px; width: 1200px; text-align: center;
27+
font-size: 21px; color: #52688f;
28+
}
29+
.old .strike {
30+
text-decoration: line-through;
31+
text-decoration-color: rgba(248, 113, 113, 0.75);
32+
text-decoration-thickness: 2px;
33+
padding: 7px 16px; border: 1px solid rgba(82, 104, 143, 0.5); border-radius: 9px;
34+
margin: 0 8px;
35+
}
36+
.title {
37+
position: absolute; top: 190px; width: 1200px; text-align: center;
38+
font-size: 56px; font-weight: 700; color: #eaf2ff;
39+
text-shadow: 0 0 44px rgba(59, 130, 246, 0.5);
40+
line-height: 1.2;
41+
}
42+
.title .glow { color: #67e8f9; text-shadow: 0 0 34px rgba(34, 211, 238, 0.8); }
43+
.chain {
44+
position: absolute; top: 352px; width: 1200px; text-align: center;
45+
font-size: 33px; color: #b7cdf3;
46+
}
47+
.chain .obj { color: #67e8f9; text-shadow: 0 0 22px rgba(34, 211, 238, 0.55); }
48+
.chain .dot { color: #818cf8; }
49+
.chain .fn { color: #60a5fa; }
50+
.chain .num { color: #f0abfc; }
51+
.rule {
52+
position: absolute; top: 424px; left: 420px; width: 360px; height: 2px;
53+
background: linear-gradient(90deg, rgba(34,211,238,0), rgba(34,211,238,0.85), rgba(34,211,238,0));
54+
box-shadow: 0 0 20px rgba(34, 211, 238, 0.7);
55+
}
56+
.footer {
57+
position: absolute; top: 462px; width: 1200px; text-align: center;
58+
font-size: 22px; color: #52688f; line-height: 1.6;
59+
}
60+
.footer b { color: #93c5fd; font-weight: 500; }
61+
.brand {
62+
position: absolute; bottom: 44px; width: 1200px; text-align: center;
63+
font-size: 24px; letter-spacing: 0.08em; color: #7da4e8;
64+
}
65+
.brand b { color: #e0ecff; }
66+
</style>
67+
</head>
68+
<body>
69+
<div class="grid"></div>
70+
<div class="old">
71+
<span class="strike">flatten into stores</span>
72+
<span class="strike">pass IDs, not references</span>
73+
<span class="strike">selector ceremony</span>
74+
</div>
75+
<div class="title">The object graph<br/>they <span class="glow">took from you</span></div>
76+
<div class="chain">
77+
<span class="obj">workspaceSet</span><span class="dot">.</span><span class="obj">active</span><span class="dot">.</span><span class="obj">editor</span><span class="dot">.</span><span class="fn">selectLine</span><span class="dot">(</span><span class="num">42</span><span class="dot">)</span>
78+
</div>
79+
<div class="rule"></div>
80+
<div class="footer">two defects fixed by construction · any depth · reactivity rides the hops</div>
81+
<div class="brand"><b>ivue</b> blog</div>
82+
</body>
83+
</html>

docs_v2/blog/index.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ pageClass: blog-index
77
# Blog
88

99
<div class="blog-list">
10+
<a class="blog-card" href="/blog/the-object-graph-they-took">
11+
<img class="thumb" src="/blog/the-object-graph-they-took.png" alt="The object graph they took from you" width="1200" height="630" loading="lazy" />
12+
<div class="body">
13+
<div class="meta"><span class="date">July 2026</span></div>
14+
<h2>The object graph they took from you</h2>
15+
<p class="excerpt">Frontend never proved object graphs wrong — it fled two mechanical defects, then canonized the flight. Fix them and workspaceSet.active.editor.selectLine() comes home.</p>
16+
<span class="go">Read the post →</span>
17+
</div>
18+
</a>
1019
<a class="blog-card" href="/blog/reactive-is-all-you-need">
1120
<img class="thumb" src="/blog/reactive-is-all-you-need.png" alt="Reactive() is all you need" width="1200" height="630" loading="lazy" />
1221
<div class="body">
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
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+
![The object graph they took from you](/blog/the-object-graph-they-took.png)
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.
249 KB
Loading

0 commit comments

Comments
 (0)