## Summary
Architecture documentation proposing an Entity Component System for the
litegraph layer.
```mermaid
graph LR
subgraph Today["Today: Spaghetti"]
God["🍝 God Objects"]
Circ["🔄 Circular Deps"]
Mut["💥 Render Mutations"]
end
subgraph Tomorrow["Tomorrow: ECS"]
ID["🏷️ Branded IDs"]
Comp["📦 Components"]
Sys["⚙️ Systems"]
World["🌍 World"]
end
God -->|"decompose"| Comp
Circ -->|"flatten"| ID
Mut -->|"separate"| Sys
Comp --> World
ID --> World
Sys -->|"query"| World
```
## Changes
- **What**: ADR 0008 + 4 architecture docs (no code changes)
- `docs/adr/0008-entity-component-system.md` — entity taxonomy, branded
IDs, component decomposition, migration strategy
- `docs/architecture/entity-interactions.md` — as-is Mermaid diagrams of
all entity relationships
- `docs/architecture/entity-problems.md` — structural problems with
file:line evidence
- `docs/architecture/ecs-target-architecture.md` — target architecture
diagrams
- `docs/architecture/proto-ecs-stores.md` — analysis of existing Pinia
stores as proto-ECS patterns
## Review Focus
- Does the entity taxonomy (Node, Link, Subgraph, Widget, Slot, Reroute,
Group) cover all cases?
- Are the component decompositions reasonable starting points?
- Is the migration strategy (bridge layer, incremental extraction)
feasible?
- Are there entity interactions or problems we missed?
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10420-docs-ADR-0008-Entity-Component-System-32d6d73d365081feb048d16a5231d350)
by [Unito](https://www.unito.io)
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
16 KiB
ECS Target Architecture
This document describes the target ECS architecture for the litegraph entity system. It shows how the entities and interactions from the current system transform under ECS, and how the structural problems are resolved. For the full design rationale, see ADR 0008.
1. World Overview
The World is the single source of truth for runtime entity state in one workflow instance. Entities are just branded IDs. Components are plain data objects. Systems are functions that query the World.
graph TD
subgraph World["World (Central Registry)"]
direction TB
NodeStore["Nodes
Map<NodeEntityId, NodeComponents>"]
LinkStore["Links
Map<LinkEntityId, LinkComponents>"]
ScopeRegistry["Graph Scopes
Map<GraphId, ParentGraphId | null>"]
WidgetStore["Widgets
Map<WidgetEntityId, WidgetComponents>"]
SlotStore["Slots
Map<SlotEntityId, SlotComponents>"]
RerouteStore["Reroutes
Map<RerouteEntityId, RerouteComponents>"]
GroupStore["Groups
Map<GroupEntityId, GroupComponents>"]
end
subgraph Systems["Systems (Behavior)"]
direction TB
RS["RenderSystem"]
SS["SerializationSystem"]
CS["ConnectivitySystem"]
LS["LayoutSystem"]
ES["ExecutionSystem"]
VS["VersionSystem"]
end
RS -->|reads| World
SS -->|reads/writes| World
CS -->|reads/writes| World
LS -->|reads/writes| World
ES -->|reads| World
VS -->|reads/writes| World
style World fill:#1a1a2e,stroke:#16213e,color:#e0e0e0
style Systems fill:#0f3460,stroke:#16213e,color:#e0e0e0
Entity IDs
graph LR
subgraph "Branded IDs (compile-time distinct)"
NID["NodeEntityId
number & { __brand: 'NodeEntityId' }"]
LID["LinkEntityId
number & { __brand: 'LinkEntityId' }"]
WID["WidgetEntityId
number & { __brand: 'WidgetEntityId' }"]
SLID["SlotEntityId
number & { __brand: 'SlotEntityId' }"]
RID["RerouteEntityId
number & { __brand: 'RerouteEntityId' }"]
GID["GroupEntityId
number & { __brand: 'GroupEntityId' }"]
end
GRID["GraphId
string & { __brand: 'GraphId' }"]:::scopeId
NID -.-x LID
LID -.-x WID
WID -.-x SLID
classDef scopeId fill:#2a2a4a,stroke:#4a4a6a,color:#e0e0e0,stroke-dasharray:5
linkStyle 0 stroke:red,stroke-dasharray:5
linkStyle 1 stroke:red,stroke-dasharray:5
linkStyle 2 stroke:red,stroke-dasharray:5
Red dashed lines = compile-time errors if mixed. No more accidentally passing a LinkId where a NodeId is expected.
Note: GraphId is a scope identifier, not an entity ID. It identifies which graph an entity belongs to. Subgraphs are nodes with a SubgraphStructure component — see Subgraph Boundaries.
Linked subgraphs and instance-varying state
Linked subgraph definitions can be shared structurally, but mutable values are instance-scoped.
- Shared definition-level data (interface shape, default metadata) can be reused across instances.
- Runtime state (
WidgetValue, execution/transient state, selection) is scoped to the containinggraphIdchain inside one World instance. - "Single source of truth" therefore means one source per workflow instance, not one global source across all linked instances.
Recursive subgraphs without inheritance
Recursive containment is represented through graph scopes rather than
Subgraph extends LGraph inheritance.
- A subgraph node points to a child graph via
SubgraphStructure.childGraphId. - The scope registry stores
childGraphId -> parentGraphIdlinks. - Depth queries traverse this scope DAG, then filter entities by
graphScope.
2. Component Composition
Node: Before vs After
graph LR
subgraph Before["LGraphNode (monolith)"]
direction TB
B1["pos, size, bounding"]
B2["color, bgcolor, title"]
B3["type, category, nodeData"]
B4["inputs[], outputs[]"]
B5["order, mode, flags"]
B6["properties, properties_info"]
B7["widgets[]"]
B8["serialize(), configure()"]
B9["drawSlots(), drawWidgets()"]
B10["execute(), triggerSlot()"]
B11["graph._version++"]
B12["connect(), disconnect()"]
end
subgraph After["NodeEntityId + Components"]
direction TB
A1["Position
{ pos, size, bounding }"]
A2["NodeVisual
{ color, bgcolor, boxcolor, title }"]
A3["NodeType
{ type, category, nodeData }"]
A4["Connectivity
{ inputSlotIds[], outputSlotIds[] }"]
A5["Execution
{ order, mode, flags }"]
A6["Properties
{ properties, propertiesInfo }"]
A7["WidgetContainer
{ widgetIds[] }"]
end
B1 -.-> A1
B2 -.-> A2
B3 -.-> A3
B4 -.-> A4
B5 -.-> A5
B6 -.-> A6
B7 -.-> A7
B8 -.->|"moves to"| SYS1["SerializationSystem"]
B9 -.->|"moves to"| SYS2["RenderSystem"]
B10 -.->|"moves to"| SYS3["ExecutionSystem"]
B11 -.->|"moves to"| SYS4["VersionSystem"]
B12 -.->|"moves to"| SYS5["ConnectivitySystem"]
style Before fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
style After fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
Link: Before vs After
graph LR
subgraph Before["LLink (class)"]
direction TB
B1["origin_id, origin_slot
target_id, target_slot, type"]
B2["color, path, _pos"]
B3["_dragging, data"]
B4["disconnect()"]
B5["resolve()"]
end
subgraph After["LinkEntityId + Components"]
direction TB
A1["LinkEndpoints
{ originId, originSlot,
targetId, targetSlot, type }"]
A2["LinkVisual
{ color, path, centerPos }"]
A3["LinkState
{ dragging, data }"]
end
B1 -.-> A1
B2 -.-> A2
B3 -.-> A3
B4 -.->|"moves to"| SYS1["ConnectivitySystem"]
B5 -.->|"moves to"| SYS2["ConnectivitySystem"]
style Before fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
style After fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
Widget: Before vs After
graph LR
subgraph Before["BaseWidget (class)"]
direction TB
B1["name, type, _node"]
B2["value, options, serialize"]
B3["computedHeight, margin"]
B4["drawWidget(), onClick()"]
B5["useWidgetValueStore()"]
B6["usePromotionStore()"]
end
subgraph After["WidgetEntityId + Components"]
direction TB
A1["WidgetIdentity
{ name, widgetType, parentNodeId }"]
A2["WidgetValue
{ value, options, serialize }"]
A3["WidgetLayout
{ computedHeight, constraints }"]
end
B1 -.-> A1
B2 -.-> A2
B3 -.-> A3
B4 -.->|"moves to"| SYS1["RenderSystem"]
B5 -.->|"absorbed by"| SYS2["World (is the store)"]
B6 -.->|"moves to"| SYS3["PromotionSystem"]
style Before fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
style After fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
3. System Architecture
Systems are pure functions that query the World for entities with specific component combinations. Each system owns exactly one concern.
graph TD
subgraph InputPhase["Input Phase"]
UserInput["User Input
(pointer, keyboard)"]
APIInput["API Input
(backend execution results)"]
end
subgraph UpdatePhase["Update Phase (ordered)"]
direction TB
CS["ConnectivitySystem
Manages link/slot mutations.
Writes: LinkEndpoints, SlotConnection,
Connectivity"]
VS["VersionSystem
Centralizes change tracking.
Replaces 15+ scattered _version++.
Writes: version counter"]
LS["LayoutSystem
Computes positions and sizes.
Runs BEFORE render, not during.
Reads: Connectivity, WidgetContainer
Writes: Position, SlotVisual, WidgetLayout"]
ES["ExecutionSystem
Determines run order.
Reads: Connectivity, Execution
Writes: Execution.order"]
end
subgraph RenderPhase["Render Phase (read-only)"]
RS["RenderSystem
Pure read of components.
No state mutation.
Reads: Position, *Visual, *Layout"]
end
subgraph PersistPhase["Persist Phase"]
SS["SerializationSystem
Reads/writes all components.
Handles workflow JSON."]
end
UserInput --> CS
APIInput --> ES
CS --> VS
VS --> LS
LS --> RS
CS --> SS
style InputPhase fill:#2a2a4a,stroke:#3a3a5a,color:#e0e0e0
style UpdatePhase fill:#1a3a2a,stroke:#2a4a3a,color:#e0e0e0
style RenderPhase fill:#3a2a1a,stroke:#4a3a2a,color:#e0e0e0
style PersistPhase fill:#2a2a3a,stroke:#3a3a4a,color:#e0e0e0
System-Component Access Matrix
graph LR
subgraph Systems
RS["Render"]
SS["Serialization"]
CS["Connectivity"]
LS["Layout"]
ES["Execution"]
VS["Version"]
end
subgraph Components
Pos["Position"]
NV["NodeVisual"]
NT["NodeType"]
Con["Connectivity"]
Exe["Execution"]
Props["Properties"]
WC["WidgetContainer"]
LE["LinkEndpoints"]
LV["LinkVisual"]
SC["SlotConnection"]
SV["SlotVisual"]
WVal["WidgetValue"]
WL["WidgetLayout"]
end
RS -.->|read| Pos
RS -.->|read| NV
RS -.->|read| LV
RS -.->|read| SV
RS -.->|read| WL
LS -->|write| Pos
LS -->|write| SV
LS -->|write| WL
LS -.->|read| Con
LS -.->|read| WC
CS -->|write| LE
CS -->|write| SC
CS -->|write| Con
ES -.->|read| Con
ES -->|write| Exe
SS -.->|read/write| Pos
SS -.->|read/write| NT
SS -.->|read/write| Props
SS -.->|read/write| WVal
SS -.->|read/write| LE
VS -.->|read| Pos
VS -.->|read| Con
4. Dependency Flow
Before: Tangled References
graph TD
Node["LGraphNode"] <-->|"circular"| Graph["LGraph"]
Graph <-->|"circular"| Subgraph["Subgraph"]
Node -->|"this.graph._links"| Links["LLink Map"]
Node -->|"this.graph.getNodeById"| Node
Canvas["LGraphCanvas"] -->|"node.graph._version++"| Graph
Canvas -->|"node.graph.remove(node)"| Graph
Widget["BaseWidget"] -->|"useWidgetValueStore()"| Store1["Pinia Store"]
Widget -->|"usePromotionStore()"| Store2["Pinia Store"]
Node -->|"useLayoutMutations()"| Store3["Layout Store"]
Graph -->|"useLayoutMutations()"| Store3
LLink["LLink"] -->|"useLayoutMutations()"| Store3
style Node fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
style Graph fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
style Canvas fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
style Widget fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
After: Unidirectional Data Flow
graph TD
subgraph Systems["Systems"]
RS["RenderSystem"]
CS["ConnectivitySystem"]
LS["LayoutSystem"]
ES["ExecutionSystem"]
SS["SerializationSystem"]
VS["VersionSystem"]
end
World["World
(instance-scoped source of truth)"]
subgraph Components["Component Stores"]
Pos["Position"]
Vis["*Visual"]
Con["Connectivity"]
Val["*Value"]
end
Systems -->|"query/mutate"| World
World -->|"contains"| Components
style Systems fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
style World fill:#1a1a4a,stroke:#2a2a6a,color:#e0e0e0
style Components fill:#1a3a3a,stroke:#2a4a4a,color:#e0e0e0
Key differences:
- No circular dependencies: entities are IDs, not class instances
- No Demeter violations: systems query the World directly, never reach through entities
- No scattered store access: the World is the store; systems are the only writers
- Unidirectional: Input → Systems → World → Render (no back-edges)
- Instance safety: linked definitions can be reused without forcing shared mutable widget/execution state across instances
5. Problem Resolution Map
How each problem from entity-problems.md is resolved:
graph LR
subgraph Problems["Current Problems"]
P1["God Objects
(9k+ line classes)"]
P2["Circular Deps
(LGraph ↔ Subgraph)"]
P3["Mixed Concerns
(render + domain + state)"]
P4["Inconsistent IDs
(number|string, no safety)"]
P5["Demeter Violations
(graph._links, graph._version++)"]
P6["Scattered Side Effects
(15+ _version++ sites)"]
P7["Render-Time Mutations
(arrange() during draw)"]
end
subgraph Solutions["ECS Solutions"]
S1["Components: small, focused
data objects (5-10 fields each)"]
S2["Entities are just IDs.
No inheritance hierarchy.
Subgraph = node + component."]
S3["One system per concern.
Systems don't overlap."]
S4["Branded per-kind IDs.
Compile-time type errors."]
S5["Systems query World.
No entity→entity refs."]
S6["VersionSystem owns
all change tracking."]
S7["LayoutSystem runs in
update phase, before render.
RenderSystem is read-only."]
end
P1 --> S1
P2 --> S2
P3 --> S3
P4 --> S4
P5 --> S5
P6 --> S6
P7 --> S7
style Problems fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
style Solutions fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
6. Migration Bridge
The migration is incremental. During the transition, a bridge layer keeps legacy class properties and ECS components in sync.
sequenceDiagram
participant Legacy as Legacy Code
participant Class as LGraphNode (class)
participant Bridge as Bridge Adapter
participant World as World (ECS)
participant New as New Code / Systems
Note over Legacy,New: Phase 1: Bridge reads from class, writes to World
Legacy->>Class: node.pos = [100, 200]
Class->>Bridge: pos setter intercepted
Bridge->>World: world.setComponent(nodeId, Position, { pos: [100, 200] })
New->>World: world.getComponent(nodeId, Position)
World-->>New: { pos: [100, 200], size: [...] }
Note over Legacy,New: Phase 2: New features build on ECS directly
New->>World: world.setComponent(nodeId, Position, { pos: [150, 250] })
World->>Bridge: change detected
Bridge->>Class: node._pos = [150, 250]
Legacy->>Class: node.pos
Class-->>Legacy: [150, 250]
Note over Legacy,New: Phase 3: Legacy code migrated, bridge removed
New->>World: world.getComponent(nodeId, Position)
World-->>New: { pos: [150, 250] }
Incremental layout/render separation
Layout extraction is staged by node family, not all-at-once:
- Mark
arrange()as deprecated in render paths and collect call-site telemetry. - Run
LayoutSystemduring update for a selected node family behind a feature gate. - Keep a temporary compatibility fallback for un-migrated node families only.
- Remove the fallback once parity tests and frame-time budgets pass.
This keeps RenderSystem read-only for migrated families while preserving
incremental rollout safety.
Migration Phases
graph LR
subgraph Phase1["Phase 1: Types Only"]
T1["Define branded IDs"]
T2["Define component interfaces"]
T3["Define World type"]
end
subgraph Phase2["Phase 2: Bridge"]
B1["Bridge adapters
class ↔ World sync"]
B2["New features use
World as source"]
B3["Old code unchanged"]
end
subgraph Phase3["Phase 3: Extract"]
E1["Migrate one component
at a time"]
E2["Deprecate class
properties"]
E3["Systems replace
methods"]
end
subgraph Phase4["Phase 4: Clean"]
C1["Remove bridge"]
C2["Remove legacy classes"]
C3["Systems are sole
behavior layer"]
end
Phase1 --> Phase2 --> Phase3 --> Phase4
style Phase1 fill:#1a2a4a,stroke:#2a3a5a,color:#e0e0e0
style Phase2 fill:#1a3a3a,stroke:#2a4a4a,color:#e0e0e0
style Phase3 fill:#2a3a1a,stroke:#3a4a2a,color:#e0e0e0
style Phase4 fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
This diagram is intentionally high level. The operational Phase 4 -> 5 entry criteria (compatibility matrix, bridge fallback usage, rollback requirements) are defined in ecs-migration-plan.md.