Files
ComfyUI_frontend/docs/adr/0008-entity-component-system.md
Alexander Brown 3e197b5c57 docs: ADR 0008 — Entity Component System (#10420)
## 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>
2026-03-26 16:14:44 -07:00

15 KiB

8. Entity Component System

Date: 2026-03-23

Status

Proposed

Context

The litegraph layer is built on deeply coupled OOP classes (LGraphNode, LLink, Subgraph, BaseWidget, Reroute, LGraphGroup, SlotBase). Each entity directly references its container and children — nodes hold widget arrays, widgets back-reference their node, links reference origin/target node IDs, subgraphs extend the graph class, and so on.

This coupling makes it difficult to:

  • Add cross-cutting concerns (undo/redo, serialization, multiplayer CRDT sync, rendering optimization) without modifying every class
  • Test individual aspects of an entity in isolation
  • Evolve rendering, serialization, and execution logic independently
  • Implement the CRDT-based layout system proposed in ADR 0003

An Entity Component System (ECS) separates identity (entities), data (components), and behavior (systems), enabling each concern to evolve independently.

Current pain points

  • God objects: LGraphNode (~2000+ lines) mixes position, rendering, connectivity, execution, serialization, and input handling
  • Circular dependencies: LGraphSubgraph, LGraphNodeLGraphCanvas, requiring careful import ordering and barrel exports
  • Tight rendering coupling: Visual properties (color, position, bounding rect) are interleaved with domain logic (execution order, slot types)
  • No unified entity model: Each entity kind uses different ID types, ownership patterns, and lifecycle management

Decision

Adopt an Entity Component System architecture for the graph domain model. This ADR defines the entity taxonomy, ID strategy, and component decomposition. Implementation will be incremental — existing classes remain untouched initially and will be migrated piecewise.

Entity Taxonomy

Six entity kinds, each with a branded ID type:

Entity Kind Current Class(es) Current ID Branded ID
Node LGraphNode NodeId = number | string NodeEntityId
Link LLink LinkId = number LinkEntityId
Widget BaseWidget subclasses (25+) name + parent node WidgetEntityId
Slot SlotBase / INodeInputSlot / INodeOutputSlot index on parent node SlotEntityId
Reroute Reroute RerouteId = number RerouteEntityId
Group LGraphGroup number GroupEntityId

Subgraphs are not a separate entity kind. A subgraph is a node with a SubgraphStructure component. See Subgraph Boundaries and Widget Promotion for the full design rationale.

Branded ID Design

Each entity kind gets a nominal/branded type wrapping its underlying primitive. The brand prevents accidental cross-kind usage at compile time while remaining structurally compatible with existing ID types:

type NodeEntityId = number & { readonly __brand: 'NodeEntityId' }
type LinkEntityId = number & { readonly __brand: 'LinkEntityId' }
type WidgetEntityId = number & { readonly __brand: 'WidgetEntityId' }
type SlotEntityId = number & { readonly __brand: 'SlotEntityId' }
type RerouteEntityId = number & { readonly __brand: 'RerouteEntityId' }
type GroupEntityId = number & { readonly __brand: 'GroupEntityId' }

// Scope identifier, not an entity ID
type GraphId = string & { readonly __brand: 'GraphId' }

Widgets and Slots currently lack independent IDs. The ECS will assign synthetic IDs at entity creation time via an auto-incrementing counter (matching the pattern used by lastNodeId, lastLinkId, etc. in LGraphState).

Component Decomposition

Components are plain data objects — no methods, no back-references to parent entities. Systems query components to implement behavior.

Shared Components

  • Position{ pos: Point } — used by Node, Reroute, Group
  • Dimensions{ size: Size, bounding: Rectangle } — used by Node, Group
  • Visual — rendering properties specific to each entity kind (separate interfaces, shared naming convention)

Node

Component Data (from LGraphNode)
Position pos
Dimensions size, _bounding
NodeVisual color, bgcolor, boxcolor, title
NodeType type, category, nodeData, description
Connectivity slot entity refs (replaces inputs[], outputs[])
Execution order, mode, flags
Properties properties, properties_info
WidgetContainer widget entity refs (replaces widgets[])
Component Data (from LLink)
LinkEndpoints origin_id, origin_slot, target_id, target_slot, type
LinkVisual color, path, _pos (center point)
LinkState _dragging, data

Subgraph (Node Components)

A node carrying a subgraph gains these additional components. Subgraphs are not a separate entity kind — see Subgraph Boundaries.

Component Data
SubgraphStructure graphId, typed interface (input/output names, types, slot entity refs)
SubgraphMeta name, description

Widget

Component Data (from BaseWidget)
WidgetIdentity name, type (widget type string), parent node entity ref
WidgetValue value, options, serialize flags
WidgetLayout computedHeight, layout size constraints

Slot

Component Data (from SlotBase / INodeInputSlot / INodeOutputSlot)
SlotIdentity name, type (slot type), direction (input or output), parent node ref, index
SlotConnection link (input) or links[] (output), widget locator
SlotVisual pos, boundingRect, color_on, color_off, shape

Reroute

Component Data (from Reroute)
Position pos (shared)
RerouteLinks parentId, input/output link IDs
RerouteVisual color, badge config

Group

Component Data (from LGraphGroup)
Position pos (shared)
Dimensions size, bounding
GroupMeta title, font, font_size
GroupVisual color
GroupChildren child entity refs (nodes, reroutes)

World

A central registry (the "World") maps entity IDs to their component sets. One World exists per workflow instance, containing all entities across all nesting levels. Each entity carries a graphScope identifier linking it to its containing graph. The World also maintains a scope registry mapping each graphId to its parent (or null for the root graph).

The "single source of truth" claim in this ADR is scoped to one workflow instance. In a future linked-subgraph model, shared definitions can be loaded into multiple workflow instances, but mutable runtime components (WidgetValue, execution state, selection, transient layout caches) remain instance-scoped unless explicitly declared shareable.

Subgraph recursion model

The ECS model preserves recursive nesting without inheritance. A subgraph node stores SubgraphStructure.childGraphId, and the scope registry stores childGraphId -> parentGraphId. This forms a DAG that can represent arbitrary subgraph depth.

Queries such as "all nodes at depth N" run by traversing the scope registry from the root, materializing graph IDs at depth N, and then filtering entity queries by graphScope.

Systems (future work)

Systems are pure functions that query the World for entities with specific component combinations. Initial candidates:

  • RenderSystem — queries Position + Dimensions (where present) + *Visual components
  • SerializationSystem — queries all components to produce/consume workflow JSON
  • ExecutionSystem — queries Execution + Connectivity to determine run order
  • LayoutSystem — queries Position + Dimensions + structural components for auto-layout
  • SelectionSystem — queries Position for point entities and Position + Dimensions for box hit-testing

System design is deferred to a future ADR.

Migration Strategy

  1. Define types — branded IDs, component interfaces, World type in a new src/ecs/ directory
  2. Bridge layer — adapter functions that read ECS components from existing class instances (zero-copy where possible)
  3. New features first — any new cross-cutting feature (e.g., CRDT sync) builds on ECS components rather than class properties
  4. Incremental extraction — migrate one component at a time from classes to the World, using the bridge layer for backward compatibility
  5. Deprecate class properties — once all consumers read from the World, mark class properties as deprecated

Relationship to ADR 0003 (Command Pattern / CRDT)

ADR 0003 establishes that all mutations flow through serializable, idempotent commands. This ADR (0008) defines the entity data model and the World store. They are complementary architectural layers:

  • Commands (ADR 0003) describe mutation intent — serializable objects that can be logged, replayed, sent over a wire, or undone.
  • Systems (ADR 0008) are command handlers — they validate and execute mutations against the World.
  • The World (ADR 0008) is the store — it holds component data. It does not know about commands.

The World's imperative API (setComponent, deleteEntity, etc.) is internal. External callers submit commands; the command executor wraps each in a World transaction. This is analogous to Redux: the store's internal mutation is imperative, but the public API is action-based.

For the full design showing how each lifecycle scenario maps to a command, see World API and Command Layer.

Alternatives Considered

  • Refactoring classes in place: Lower initial cost, but doesn't solve the cross-cutting concern problem. Each new feature still requires modifying multiple god objects.
  • Full rewrite: Higher risk, blocks feature work during migration. The incremental approach avoids this.
  • Using an existing ECS library (e.g., bitecs, miniplex): Adds a dependency for a domain that is specific to this project. The graph domain's component shapes don't align well with the dense numeric arrays favored by game-oriented ECS libraries. A lightweight, purpose-built approach is preferred.

Consequences

Positive

  • Cross-cutting concerns (undo/redo, CRDT sync, serialization) can be implemented as systems without modifying entity classes
  • Components are independently testable — no need to construct an entire LGraphNode to test position logic
  • Branded IDs prevent a class of bugs where IDs are accidentally used across entity kinds
  • The World provides a single source of truth for runtime entity state inside a workflow instance, simplifying debugging and state inspection
  • Aligns with the CRDT layout system direction from ADR 0003

Negative

  • Additional indirection: reading a node's position requires a World lookup instead of node.pos
  • Learning curve for contributors unfamiliar with ECS patterns
  • Migration period where both OOP and ECS patterns coexist, increasing cognitive load
  • Widgets and Slots need synthetic IDs, adding ID management complexity

Render-Loop Performance Implications and Mitigations

Replacing direct property reads (node.pos) with component lookups (world.getComponent(nodeId, Position)) does add per-read overhead in the hot render path. In modern JS engines, hot Map.get() paths are heavily optimized and are often within a low constant factor of object property reads, but this ADR treats render-loop cost as a first-class risk rather than assuming it is free.

Planned mitigations for the ECS render path:

  1. Pre-collect render queries into frame-stable caches (visibleNodeIds, visibleLinkIds, and resolved component references) and rebuild only on topology/layout dirty signals, not on every draw call.
  2. Keep archetype-style buckets for common render signatures (for example: Node = Position+Dimensions+NodeVisual, Reroute = Position+RerouteVisual) so systems iterate arrays instead of probing unrelated entities.
  3. Allow a hot-path storage upgrade behind the World API (for example, SoA-style typed arrays for Position and Dimensions) if profiling shows Map.get() dominates frame time.
  4. Gate migration of each render concern with profiling parity checks against the legacy path (same workflow, same viewport, same frame budget).
  5. Treat parity as a release gate: ECS render path must stay within agreed frame-time budgets (for example, no statistically significant regression in p95 frame time on representative 200-node and 500-node workflows).

The design goal is to preserve ECS modularity while keeping render throughput within existing frame-time budgets.

Notes

  • The 25+ widget types (BooleanWidget, NumberWidget, ComboWidget, etc.) will share the same ECS component schema. Widget-type-specific behavior lives in systems, not in component data.
  • Subgraphs are not a separate entity kind. A GraphId scope identifier (branded string) tracks which graph an entity belongs to. The scope DAG must be acyclic — see Subgraph Boundaries.
  • The existing LGraphState.lastNodeId / lastLinkId / lastRerouteId counters extend naturally to lastWidgetId and lastSlotId.
  • The internal ECS model and the serialization format are deliberately separate concerns. The SerializationSystem translates between the flat World and the nested serialization format. Backward-compatible loading of all prior workflow formats is a hard, indefinite constraint.