ADR-001: World Manager Architecture
Status: Amended Revision: v4 Implementation: Shipped First accepted: 2025-12-07 · Last amended: 2026-07-26 Relates to: #82 · #332
Context
The World class has grown to 3,073 lines with 10+ distinct responsibilities:
| Region | Lines | Responsibility |
|---|---|---|
| Entity Management | ~633 | Spawn, Despawn, Get, Has, Add, Set, Remove, naming |
| Entity Hierarchy | ~676 | Parent-child relationships, ancestors, descendants |
| Systems | ~365 | Registration, ordering, topological sort, execution |
| Events | ~189 | Component/entity lifecycle event handlers |
| Change Tracking | ~251 | Dirty flags, auto-tracking |
| Singletons | ~209 | Global data storage |
| Plugins | ~169 | Plugin lifecycle management |
| Extensions | ~107 | Plugin-provided APIs |
| Memory Statistics | ~51 | Diagnostics |
| Queries | ~39 | Delegation to QueryManager |
This violates the Single Responsibility Principle. The class is difficult to:
- Test individual concerns in isolation
- Reason about without understanding all shared state
- Modify without risk of unintended side effects
- Navigate and maintain
Decision
Refactor World into a facade pattern with specialized internal managers. The original decision named 11 managers (8 to extract plus 3 pre-existing); the pattern has since absorbed every new World concern, and the shipped architecture is:
World (facade)
├── HierarchyManager - Parent-child entity relationships
├── SystemManager - System registration, ordering, execution
├── SystemHookManager - Before/after system execution hooks
├── PluginManager - Plugin lifecycle
├── SingletonManager - Global resource storage
├── ExtensionManager - Plugin-provided APIs
├── EntityNamingManager - Entity name registration and lookup
├── EventManager - Component and entity lifecycle events
├── MessageManager - Inter-system messaging
├── TagManager - String-based entity tagging
├── ChangeTracker - Dirty flag tracking with entity reconstruction
├── ArchetypeManager - (pre-existing) Component storage
├── QueryManager - (pre-existing) Query caching
├── ComponentRegistry - (pre-existing) Component type registry
├── ComponentValidationManager - Component constraint enforcement
├── SaveManager - World persistence orchestration
├── SnapshotManager - World state serialization (static utility class)
├── SceneManager - In-memory scene lifecycle (spawn/unload/transition of tagged entity groups)
├── StatisticsManager - Memory and performance stats
└── ComponentArrayPoolManager - Component array pooling
Implementation Order
Extract managers in order of size and isolation (largest/cleanest first):
- ✅ HierarchyManager (~676 lines) - No dependencies on other inline code
- ✅ SystemManager (~365 lines) - Complex topological sort, well-bounded
- ✅ PluginManager (~169 lines) - Interacts with systems
- ✅ SingletonManager (~209 lines) - Simple key-value pattern
- ✅ ExtensionManager (~107 lines) - Plugin-provided APIs
- ✅ EntityNamingManager (~100 lines) - Entity name registration and lookup
- ✅ EventManager (~140 lines) - Consolidates EventBus, ComponentEventHandlers, EntityEventHandlers
- ✅ ChangeTracker (enhanced) - Added EntityPool dependency for entity reconstruction
Current Status: Extraction is complete. World.cs proper is 235 lines (core fields, constructor, Dispose), meeting the ~300-400 line facade target. The facade's public surface is organized as partial-class files (World.Entities.cs, World.Systems.cs, etc.) containing thin one-line delegations to managers — the partial split rejected as Option 1 proved useful as file organization on top of, not instead of, manager extraction.
Design Constraints
- Managers default to
internal(not public API); all eight managers extracted under this ADR are internal. A minority are deliberately public where users need direct access (ArchetypeManager,QueryManager,ComponentRegistry,ComponentValidationManager,ComponentArrayPoolManager,SceneManager), exposed as properties onWorld Worldremains the single entry point (facade pattern)- Public API unchanged - no breaking changes
- Each manager takes minimal dependencies
- Unit tests added for each manager before extraction
Alternatives Considered
Option 1: Partial Class Split
Split World across multiple files using partial class:
World.cs - Core fields, constructor, Dispose
World.Entities.cs - Spawn, Despawn, Get, Has, etc.
World.Hierarchy.cs - Parent/child relationships
...
Rejected because: This is cosmetic organization. The class still has 10+ responsibilities sharing mutable state. Doesn't improve testability, coupling, or maintainability.
Option 2: Extension Methods
Move stateless operations to extension methods:
public static class WorldHierarchyExtensions
{
public static IEnumerable<Entity> GetDescendants(this World world, Entity entity) { ... }
}
Rejected because: Only works for methods that don't need private state. Hierarchy needs internal dictionaries, so limited applicability.
Option 3: Defer to v1.0 (YAGNI)
Keep monolithic design through v0.x, refactor for v1.0.
Rejected because: The class has already crossed the maintainability threshold at 3,000+ lines. Waiting will make refactoring harder as more code accumulates.
Explicit Static State Exceptions
While KeenEyes follows a "no static state" principle for world isolation, there are specific cases where static state is acceptable. These are documented here for transparency.
ComponentArrayPoolManager Delegate Cache
Location: src/KeenEyes.Core/Pooling/ComponentArrayPoolManager.cs
Static fields:
rentDelegates: Dictionary<Type, RentDelegate>returnDelegates: Dictionary<Type, ReturnDelegate>lockObj: Lock for thread-safe registration
Justification:
Wraps existing global singleton -
ArrayPool<T>.Sharedis already a process-wide singleton in .NET. The delegate cache merely provides typed access to this existing global resource.Delegates are pure functions - They contain no mutable state. Each delegate simply forwards to
ArrayPool<T>.Shared.Rent()orReturn().Per-world isolation maintained - The mutable state that matters (
totalRented,totalReturned) are instance fields per-world. Only the immutable type→delegate mappings are shared.Efficiency - Caching delegates globally is more efficient than per-world caches with identical behavior. There's no benefit to having each world maintain its own identical copies.
AOT compatibility - The delegate cache enables Native AOT compilation by avoiding runtime reflection for ArrayPool access.
Idempotent registration - Multiple calls to
Register<T>()are safe and simply return if the type is already registered.
This exception does not violate per-world isolation principles because worlds cannot observe or affect each other through this shared cache. See issue #332 for the original analysis.
Consequences
Positive
- Each manager can be tested in isolation
- Clearer ownership of state and behavior
- Easier to reason about individual concerns
- Follows existing patterns (
ArchetypeManager,QueryManager) - Enables future parallelization (managers could have separate locks)
Negative
- Additional indirection (facade → manager → implementation)
- Slight increase in type count
- Migration effort required
Neutral
- Public API unchanged
- Performance impact negligible (one extra method call)
Changelog
- v4 — 2026-07-26 (living-ADR conversion): Status set to Amended (decision shipped 2025-12-07 and twice amended in place); Implementation: Shipped with no gaps — all eight planned extractions exist as internal managers. Decision diagram expanded from the original 11 managers to the 20 shipped today; obsolete "~2,272 lines" status replaced with the as-built result (World.cs is a 235-line facade with its delegation surface split across partial files); "managers are
internal" constraint amended to record the deliberately public managers (ArchetypeManager, QueryManager, ComponentRegistry, ComponentValidationManager, ComponentArrayPoolManager, SceneManager). - v3 — 2026-01-04 (49db2cc4 / issue #332): Added 'Explicit Static State Exceptions' section documenting the ComponentArrayPoolManager static delegate cache (rentDelegates/returnDelegates/lockObj) as an accepted exception to the no-static-state principle, with six justifications.
- v2 — 2025-12-07 (fdcfc15a): Progress update: marked all eight extraction phases complete, added EntityNamingManager, EventManager, and enhanced ChangeTracker to the architecture diagram, and recorded World.cs reduction from 3,073 to ~2,272 lines.
- v1 — 2025-12-07 (b25913ab / issue #82): Accepted — refactor the 3,073-line World class into a facade over specialized internal managers to restore single responsibility, testability, and maintainability.