ADR-007: Capability-Based Plugin Architecture
Status: Accepted Revision: v2 Implementation: Shipped First accepted: 2025-12-20 · Last amended: 2026-07-26 Relates to: ADR-001 (World managers) · ADR-003 (CommandBuffer)
Context
Plugins currently access World functionality through two mechanisms:
- IPluginContext - Limited interface for plugin installation
- Casting to World -
(World)context.Worldfor full access
This causes problems:
Testing Difficulty
public void Install(IPluginContext context)
{
var world = (World)context.World; // Requires concrete World
world.RegisterPrefab("Enemy", enemyPrefab);
}
Testing this plugin requires a full World instance, which:
- Creates archetype managers, query caches, etc.
- Has initialization overhead
- Cannot be mocked for specific behavior testing
Hidden Dependencies
Plugins silently depend on World features without declaring them. There's no way to know what a plugin needs without reading its implementation.
Fragile Code
If World's public API changes, plugins that cast to World break silently at runtime, not compile time.
Decision
Extract cohesive World features into capability interfaces. Plugins request specific capabilities rather than casting to World.
Capability Interfaces
| Capability | Purpose | Location |
|---|---|---|
ISystemHookCapability |
Before/after system execution hooks | Abstractions |
IPersistenceCapability |
World snapshot save/load | Abstractions |
IHierarchyCapability |
Parent-child entity relationships | Abstractions |
IValidationCapability |
Component validation configuration | Abstractions |
ITagCapability |
String-based entity tagging | Abstractions |
IStatisticsCapability |
Memory profiling | Abstractions |
IInspectionCapability |
Entity inspection for debugging | Abstractions |
ISnapshotCapability |
Basic world snapshot operations | Abstractions |
ISerializationCapability |
AOT-aware serialization via IComponentRegistry |
Abstractions |
ISaveLoadCapability |
World save/load orchestration (extends IPersistenceCapability) |
Core* |
*Core capabilities depend on Core types and therefore live in KeenEyes.Core.
An IPrefabCapability (entity templates) originally shipped with this ADR but was removed along with the runtime prefab API, which was superseded by source-generated spawn methods.
Note on ISnapshotCapability vs ISerializationCapability:
ISnapshotCapability provides simple snapshot operations (GetComponents, GetAllSingletons, SetSingleton, Clear) without exposing Core types. This allows plugins that only need basic snapshot functionality to depend solely on Abstractions.
ISerializationCapability extends ISnapshotCapability and adds IComponentRegistry access, which is required for AOT-compatible serialization where component registration happens at runtime. Both interfaces live in Abstractions: ISerializationCapability exposes the abstraction types IComponentRegistry/IComponentInfo (defined alongside it), not Core's ComponentInfo, so no Core dependency is required.
New Plugin Pattern
public void Install(IPluginContext context)
{
// Request specific capability
if (context.TryGetCapability<ITagCapability>(out var tags))
{
tags.AddTag(entity, "Enemy");
}
// Or require it (throws if unavailable)
var hierarchy = context.GetCapability<IHierarchyCapability>();
hierarchy.SetParent(child, parent);
}
Mock Implementations for Testing
KeenEyes.Testing ships seven capability mocks — MockHierarchyCapability, MockInspectionCapability, MockPersistenceCapability, MockStatisticsCapability, MockSystemHookCapability, MockTagCapability, MockValidationCapability — and MockPluginContext.SetCapability<T>() wires them into a plugin context:
// Test plugin without real World
var mockHooks = new MockSystemHookCapability();
var mockContext = new MockPluginContext()
.SetCapability<ISystemHookCapability>(mockHooks);
plugin.Install(mockContext);
// Verify behavior
Assert.True(mockHooks.WasHookAdded);
Assert.Equal(1, mockHooks.HookCount);
IWorld Already Provides Core Hierarchy
Analysis revealed that IWorld already includes basic hierarchy operations:
SetParent(Entity child, Entity parent)GetParent(Entity entity)GetChildren(Entity entity)
UI systems (UIRenderSystem, UILayoutSystem, UIHitTester) were casting to World unnecessarily. These now use IWorld directly.
Consequences
Positive
- Testability - Plugins can be tested with mocks, no real World needed
- Explicit dependencies - Plugins declare what capabilities they need
- Compile-time safety - Interface changes cause compilation errors
- Smaller test scope - Test only the capability being used
- Better documentation - Capability interfaces document available features
Negative
- More interfaces - Additional abstraction layer to understand
- Migration work - Existing plugins need updating (if any cast to World)
- Capability discovery - Developers must learn which capabilities exist
Neutral
- World still works - All capabilities are implemented by World
- No performance impact - Interface dispatch is negligible
- Gradual adoption - Plugins can migrate incrementally
Implementation
Phase 1: Core Capabilities ✅
IHierarchyCapability,IValidationCapability,ITagCapability,IStatisticsCapability— along withIInspectionCapability,IPersistenceCapability,ISnapshotCapability,ISerializationCapability, andISystemHookCapability— live in Abstractions;ISaveLoadCapabilitylives in CoreWorldimplements all capability interfaces- Seven mock capabilities plus
MockPluginContext.SetCapability<T>()ship inKeenEyes.Testing IPrefabCapabilityshipped in this phase but was later removed with the runtime prefab API (superseded by source-generated spawn methods)
Phase 2: UI System Cleanup ✅
- Updated
UIRenderSystem,UILayoutSystem,UIHitTesterto useIWorld.GetChildren()instead of casting toWorld
Phase 3: Documentation ✅
- Created this ADR
- Updated plugins.md with capability usage
- Added testing documentation
Related
- ADR-001: World Manager Architecture (internal managers)
- ADR-003: Command Buffer Abstraction (similar pattern for ICommandBuffer)
Changelog
- v2 — 2026-07-26 (living-ADR conversion): Implementation marked Shipped (status was already Accepted). Capability table corrected to as-built layout:
ISerializationCapabilitylives in Abstractions and exposesIComponentRegistry/IComponentInfo(not Core'sComponentInfo);IPrefabCapabilityremoved with the runtime prefab API (superseded by source-generated spawn methods) and Core'sISaveLoadCapabilityadded in its place. Code examples rewritten against shipped APIs:ITagCapability/IHierarchyCapabilityplugin pattern andMockPluginContext.SetCapability<T>()with the seven shipped mocks, replacing the never-shippedPluginContextBuilder/MockPrefabCapability. - v1 — 2025-12-20 (ef18fedd): Accepted — extract World features into capability interfaces so plugins request explicit capabilities via IPluginContext instead of casting to World; shipped with interfaces, World implementations, Testing mocks, and UI-system IWorld cleanup.