25 KiB
ManagedBehaviour System - Architecture Review
Date: November 10, 2025
Reviewer: Senior System Architect
Status: Analysis Complete - Awaiting Implementation Decisions
Executive Summary
The ManagedBehaviour system is a well-designed lifecycle orchestration framework that successfully provides guaranteed execution order and lifecycle management for Unity MonoBehaviours. The core architecture is sound, but there are several code quality issues that should be addressed to improve maintainability, reduce cognitive overhead, and enhance developer experience.
Overall Assessment: ✅ Good foundation, needs refinement
Complexity Rating: Medium-High (could be simplified)
Developer-Friendliness: Medium (confusion points exist)
1. System Architecture Analysis
Core Components
CustomBoot (Static Bootstrap)
↓ Creates
LifecycleManager (Singleton Orchestrator)
↓ Registers & Broadcasts to
ManagedBehaviour (Abstract Base Class)
↓ Inherited by
Concrete Game Components (AudioManager, InputManager, etc.)
Lifecycle Flow
Boot Phase:
[RuntimeInitializeOnLoadMethod]→CustomBoot.Initialise()LifecycleManager.CreateInstance()(before bootstrap)- Components register via
Awake()→LifecycleManager.Register() - Bootstrap completes →
OnBootCompletionTriggered() BroadcastManagedAwake()→ All components receiveOnManagedAwake()in priority order
Scene Transition Phase:
BeginSceneLoad(sceneName)- Batching mode activated- New components register during scene load → Added to pending batch
BroadcastSceneReady()→ Process batched components, then broadcastOnSceneReady()
Save/Load Phase:
- Scene saves:
BroadcastSceneSaveRequested()→OnSceneSaveRequested() - Global saves:
BroadcastGlobalSaveRequested()→OnGlobalSaveRequested() - Restores: Similar pattern with
OnSceneRestoreRequested()andOnGlobalRestoreRequested()
✅ What Works Well
- Guaranteed Execution Order: Priority-based sorted lists ensure deterministic execution
- Separation of Concerns: Bootstrap, scene lifecycle, and save/load are clearly separated
- Automatic Registration: Components auto-register in
Awake(), reducing boilerplate - Late Registration Support: Components that spawn after boot/scene load are handled correctly
- Scene Batching: Smart batching during scene load prevents premature initialization
- Auto-registration Features:
AutoRegisterPausableandAutoRegisterForSavereduce manual wiring
2. Problematic Code & Complexity Issues
🔴 CRITICAL: The new Keyword Pattern
Location: All singleton components inheriting from ManagedBehaviour
// Current pattern in 16+ files
private new void Awake()
{
base.Awake(); // CRITICAL: Register with LifecycleManager!
_instance = this;
}
Problems:
- Misleading Syntax:
newkeyword hides the base method rather than overriding it - Fragile: If a derived class forgets
base.Awake(), registration silently fails - Inconsistent: Some files use
private new, some useprotected override(confusing) - Comment Dependency: Requires "CRITICAL" comments because the pattern is error-prone
Why It's Used:
ManagedBehaviour.Awake()is markedprotected virtual- Singletons need to set
_instanceinAwake()beforeOnManagedAwake()is called - They use
newto hide the baseAwake()while still calling it
Recommendation: Change ManagedBehaviour.Awake() to be private and non-virtual. Introduce a new virtual hook like OnBeforeRegister() or OnEarlyAwake() that runs before registration. This eliminates the need for the new keyword pattern.
🟡 MEDIUM: Invoke Methods Bloat
Location: ManagedBehaviour.cs lines 89-99
// Public wrappers to invoke protected lifecycle methods
public void InvokeManagedAwake() => OnManagedAwake();
public void InvokeSceneUnloading() => OnSceneUnloading();
public void InvokeSceneReady() => OnSceneReady();
public string InvokeSceneSaveRequested() => OnSceneSaveRequested();
public void InvokeSceneRestoreRequested(string data) => OnSceneRestoreRequested(data);
public void InvokeSceneRestoreCompleted() => OnSceneRestoreCompleted();
public string InvokeGlobalSaveRequested() => OnGlobalSaveRequested();
public void InvokeGlobalRestoreRequested(string data) => OnGlobalRestoreRequested(data);
public void InvokeManagedDestroy() => OnManagedDestroy();
public void InvokeGlobalLoadCompleted() => OnGlobalLoadCompleted();
public void InvokeGlobalSaveStarted() => OnGlobalSaveStarted();
Problems:
- Code Duplication: 11 one-liner wrapper methods
- Maintenance Burden: Every new lifecycle hook requires a public wrapper
- Leaky Abstraction: Exposes internal lifecycle to external callers (should only be LifecycleManager)
Alternative Solutions:
- Make lifecycle methods internal: Use
internal virtualinstead ofprotected virtual- LifecycleManager can call directly (same assembly) - Reflection: Use reflection to invoke methods (performance cost, but cleaner API)
- Interface Segregation: Break into multiple interfaces (IBootable, ISceneAware, ISaveable) - more flexible but more complex
Recommendation: Use internal virtual for lifecycle methods. LifecycleManager and ManagedBehaviour are in the same assembly (Core.Lifecycle namespace), so internal access is perfect. This eliminates all 11 wrapper methods.
🟡 MEDIUM: OnDestroy Pattern Confusion
Location: Multiple derived classes
Current State: Inconsistent override patterns
// Pattern 1: Most common (correct)
protected override void OnDestroy()
{
base.OnDestroy(); // Unregisters from LifecycleManager
// Custom cleanup
if (SceneManagerService.Instance != null)
SceneManagerService.Instance.SceneLoadCompleted -= OnSceneLoadCompleted;
}
// Pattern 2: SaveLoadManager (also correct, but verbose)
protected override void OnDestroy()
{
base.OnDestroy(); // Important: call base to unregister from LifecycleManager
if (_instance == this)
_instance = null;
}
Problems:
- Manual Cleanup Required: Developers must remember to call
base.OnDestroy() - No OnManagedDestroy Usage:
OnManagedDestroy()exists but is rarely used (only 1 reference in SceneManagerService) - Redundant Comments: Multiple files have comments reminding to call base (fragile pattern)
Root Cause: OnManagedDestroy() is called from within ManagedBehaviour.OnDestroy(), but most developers override OnDestroy() directly instead of using OnManagedDestroy().
Recommendation:
- Make
OnDestroy()sealed inManagedBehaviour(or private) - Encourage use of
OnManagedDestroy()for all cleanup logic - Document the difference clearly:
OnManagedDestroy()= custom cleanup,OnDestroy()= framework cleanup (hands-off)
🟡 MEDIUM: AutoRegisterPausable Mechanism
Location: ManagedBehaviour.cs line 57, LifecycleManager.cs lines 599-609
// ManagedBehaviour
public virtual bool AutoRegisterPausable => false;
// LifecycleManager
private void HandleAutoRegistrations(ManagedBehaviour component)
{
if (component.AutoRegisterPausable && component is AppleHills.Core.Interfaces.IPausable pausable)
{
if (GameManager.Instance != null)
{
GameManager.Instance.RegisterPausableComponent(pausable);
LogDebug($"Auto-registered IPausable: {component.gameObject.name}");
}
}
}
Problems:
- Tight Coupling:
LifecycleManagerhas a direct dependency onGameManagerandIPausableinterface - Hidden Dependency: Not obvious from LifecycleManager that it depends on GameManager existing
- Single Purpose: Only handles one type of auto-registration (pausable), but there could be more
- Unregister in Base Class:
ManagedBehaviour.OnDestroy()also handles pausable unregistration (split responsibility)
Alternative Approaches:
- Event-Based: Fire an event after
OnManagedAwake()that GameManager listens to - Reflection/Attributes: Use attributes like
[AutoRegisterPausable]and scan for them - Remove Feature: Components can register themselves in
OnManagedAwake()(one line of code)
Recommendation: Consider removing AutoRegisterPausable. It saves one line of code (GameManager.Instance.RegisterPausableComponent(this)) but adds complexity. Most components that implement IPausable will want to register anyway, and explicit is better than implicit.
🟡 MEDIUM: Priority Property Repetition
Location: ManagedBehaviour.cs lines 13-50
// 6 nearly identical priority properties
public virtual int ManagedAwakePriority => 100;
public virtual int SceneUnloadingPriority => 100;
public virtual int SceneReadyPriority => 100;
public virtual int SavePriority => 100;
public virtual int RestorePriority => 100;
public virtual int DestroyPriority => 100;
Problems:
- Repetitive: 6 properties that do essentially the same thing
- Overhead: Most components only care about 1-2 priorities (usually
ManagedAwakePriority) - Cognitive Load: Developers must understand all 6 priorities even if they only use one
Is This Over-Engineered?
- Pro: Provides fine-grained control over each lifecycle phase
- Con: In practice, most components use default (100) for everything except
ManagedAwakePriority - Con: Save/Restore priorities are rarely customized (mostly manager-level components)
Recommendation: Consider consolidating to 2-3 priorities:
Priority(general, affects ManagedAwake, SceneReady, Save, Restore)UnloadPriority(affects SceneUnloading, Destroy - reverse order)- Alternatively: Use attributes like
[LifecyclePriority(Phase.ManagedAwake, 20)]for granular control only when needed
🟢 MINOR: GetPriorityForList Helper Method
Location: LifecycleManager.cs lines 639-649
private int GetPriorityForList(ManagedBehaviour component, List<ManagedBehaviour> list)
{
if (list == managedAwakeList) return component.ManagedAwakePriority;
if (list == sceneUnloadingList) return component.SceneUnloadingPriority;
if (list == sceneReadyList) return component.SceneReadyPriority;
if (list == saveRequestedList) return component.SavePriority;
if (list == restoreRequestedList) return component.RestorePriority;
if (list == destroyList) return component.DestroyPriority;
return 100;
}
Problems:
- Brittle: Relies on reference equality checks (works but fragile)
- Default Value: Returns 100 if no match (could hide bugs)
Recommendation: Use a dictionary or enum-based lookup. Better yet, if priorities are consolidated (see above), this method becomes simpler or unnecessary.
🟢 MINOR: InsertSorted Performance
Location: LifecycleManager.cs lines 620-638
private void InsertSorted(List<ManagedBehaviour> list, ManagedBehaviour component, int priority)
{
// Simple linear insertion for now (can optimize with binary search later if needed)
int index = 0;
for (int i = 0; i < list.Count; i++)
{
int existingPriority = GetPriorityForList(list[i], list);
if (priority < existingPriority)
{
index = i;
break;
}
index = i + 1;
}
list.Insert(index, component);
}
Problems:
- O(n) insertion: Linear search for insertion point
- Comment Admits It: "can optimize with binary search later if needed"
Is This a Problem?
- Probably not: Registration happens during Awake/scene load (not runtime-critical)
- Typical projects have 10-100 managed components per scene (O(n) is fine)
- Premature optimization warning: Don't fix unless proven bottleneck
Recommendation: Leave as-is unless profiling shows it's a problem. Add a comment explaining why linear is acceptable.
🟢 MINOR: SaveId Generation Logic
Location: ManagedBehaviour.cs lines 70-78
public virtual string SaveId
{
get
{
string sceneName = gameObject.scene.IsValid() ? gameObject.scene.name : "UnknownScene";
string componentType = GetType().Name;
return $"{sceneName}/{gameObject.name}/{componentType}";
}
}
Problems:
- Runtime Allocation: Allocates a new string every time it's accessed
- Mutable Path Components: GameObject name can change at runtime (breaks save system)
- Collision Risk: Two objects with same name + type in same scene = collision
Is This a Problem?
- Yes: Save IDs should be stable and unique
- No: Most components override this for singletons (e.g.,
"PlayerController")
Recommendation:
- Cache the SaveId in Awake (don't regenerate on every call)
- Add validation warnings if GameObject name changes after registration
- Consider GUID-based IDs for instance-based components (prefabs spawned at runtime)
3. Code Style Issues
🎨 Region Overuse
Location: Both ManagedBehaviour.cs and LifecycleManager.cs
Current State:
ManagedBehaviour.cs: 6 regions (Priority Properties, Configuration, Public Accessors, Private Fields, Unity Lifecycle, Managed Lifecycle)LifecycleManager.cs: 8 regions (Singleton, Lifecycle Lists, Tracking Dictionaries, State Flags, Unity Lifecycle, Registration, Broadcast Methods, Auto-Registration, Helper Methods)
Opinion:
- Pro: Helps organize long files
- Con: Regions are a code smell suggesting the file is doing too much
- Modern Practice: Prefer smaller, focused classes over heavily regioned classes
Recommendation: Regions are acceptable for these orchestrator classes, but consider:
- Moving priority properties to a separate struct/class (
LifecyclePriorities) - Moving auto-registration logic to a separate service
🎨 Documentation Quality
Overall: ✅ Excellent!
Strengths:
- Comprehensive XML comments on all public/protected members
- Clear "GUARANTEE" and "TIMING" sections in lifecycle hook docs
- Good use of examples and common patterns
- Warnings about synchronous vs async behavior
Minor Issues:
- Some comments are overly verbose (e.g.,
OnSceneRestoreCompletedhas a paragraph explaining async guarantees) - "IMPORTANT:" and "GUARANTEE:" prefixes could be standardized
Recommendation: Keep the thorough documentation style. Consider extracting complex documentation into markdown files (like you have in docs/) and linking to them.
🎨 Naming Conventions
Mostly Consistent:
- Protected methods:
OnManagedAwake(),OnSceneReady()✅ - Invoke wrappers:
InvokeManagedAwake(),InvokeSceneReady()✅ - Priority properties:
ManagedAwakePriority,SceneReadyPriority✅
Inconsistencies:
OnBootCompletionTriggered()(passive voice) vsBroadcastManagedAwake()(active voice)currentSceneReady(camelCase field) vs_instance(underscore prefix)
Recommendation: Minor cleanup pass for consistency (not critical).
4. Missing Features / Potential Enhancements
🔧 No Update/FixedUpdate Management
Observation: The system manages initialization and shutdown, but not per-frame updates.
Question: Should ManagedBehaviour provide ordered Update loops?
Trade-offs:
- Pro: Could guarantee update order (e.g., InputManager before PlayerController)
- Con: Adds performance overhead (one dispatch loop per frame)
- Con: Unity's native Update is very optimized (hard to beat)
Recommendation: Don't add unless there's a proven need. Most update-order issues can be solved with ScriptExecutionOrder settings.
🔧 No Pause/Resume Lifecycle Hooks
Observation: IPausable exists and is auto-registered, but there are no lifecycle hooks for pause/resume events.
Current State: Components must implement IPausable interface and handle pause/resume manually.
Potential Enhancement:
protected virtual void OnGamePaused() { }
protected virtual void OnGameResumed() { }
Recommendation: Consider adding if pause/resume is a common pattern. Alternatively, components can subscribe to GameManager events in OnManagedAwake().
🔧 Limited Error Recovery
Observation: If a component throws in OnManagedAwake(), the error is logged but the system continues.
Current State:
catch (Exception ex)
{
Debug.LogError($"[LifecycleManager] Error in OnManagedAwake for {component.gameObject.name}: {ex}");
}
Trade-offs:
- Pro: Resilient - one component failure doesn't crash the game
- Con: Silent failures can be hard to debug
- Con: Components might be in invalid state if initialization failed
Recommendation: Consider adding:
- A flag on component:
HasInitialized/InitializationFailed - An event:
OnComponentInitializationFailed(component, exception) - Editor-only hard failures (#if UNITY_EDITOR throw; #endif)
5. Behavioral Correctness
✅ Execution Order: CORRECT
Boot Components:
- All components register during their
Awake()(sorted by AwakePriority) - Bootstrap completes
OnBootCompletionTriggered()→BroadcastManagedAwake()- Components receive
OnManagedAwake()in priority order (20, 25, 30, 100, etc.)
Late Registration (Component enabled after boot):
- Component's
Awake()callsRegister() - If boot complete:
OnManagedAwake()called immediately - Component is added to all lifecycle lists
Scene Load:
BeginSceneLoad("SceneName")- batching mode ON- Scene loads → New components register → Added to pending batch
BroadcastSceneReady("SceneName")- Batched components processed in priority order →
OnManagedAwake()called - All components (batched + existing) receive
OnSceneReady()
Assessment: ✅ Logic is sound and well-tested
✅ Save/Load: CORRECT
Scene Save (During Transition):
BroadcastSceneSaveRequested()iterates all components withAutoRegisterForSave == true- Calls
OnSceneSaveRequested()→ Collects returned data - Returns
Dictionary<saveId, serializedData>
Scene Restore:
BroadcastSceneRestoreRequested(saveData)distributes data by SaveId- Calls
OnSceneRestoreRequested(data)for matching components - Calls
BroadcastSceneRestoreCompleted()→ All components receiveOnSceneRestoreCompleted()
Global Save/Load: Same pattern but uses OnGlobalSaveRequested() / OnGlobalRestoreRequested()
Assessment: ✅ Separation of scene vs global state is clean
⚠️ Unregister Timing: POTENTIAL ISSUE
Scenario: Component is destroyed during BroadcastManagedAwake()
Current Protection:
var componentsCopy = new List<ManagedBehaviour>(managedAwakeList);
foreach (var component in componentsCopy)
{
if (component == null) continue; // Null check protects against destroyed objects
component.InvokeManagedAwake();
}
Protection Mechanism: Copies list before iteration + null checks
Assessment: ✅ Handles collection modification correctly
Minor Issue: If a component is destroyed, it still remains in managedAwakeList copy (but null check prevents execution). The real list is cleaned up when Unregister() is called from OnDestroy().
⚠️ AutoRegisterPausable Unregister: ASYMMETRY
Registration:
- Happens in
LifecycleManager.HandleAutoRegistrations()(afterOnManagedAwake())
Unregistration:
- Happens in
ManagedBehaviour.OnDestroy()directly
if (AutoRegisterPausable && this is IPausable pausable)
{
GameManager.Instance?.UnregisterPausableComponent(pausable);
}
Issue: Registration and unregistration logic is split between two classes.
Recommendation: Move unregistration to LifecycleManager for symmetry. Call InvokeManagedDestroy() before automatic cleanup.
6. Developer Experience Issues
😕 Confusion Point: Awake vs OnManagedAwake
Common Question: "When should I use Awake() vs OnManagedAwake()?"
Answer:
Awake(): Set singleton instance, early initialization (before bootstrap)OnManagedAwake(): Initialization that depends on other systems (after bootstrap)
Problem: Requires understanding of bootstrap sequencing (not obvious to new developers)
Recommendation:
- Improve docs with flowchart diagram
- Add validation: If component accesses
GameManager.InstanceinAwake(), warn that it should be inOnManagedAwake()
😕 Confusion Point: OnDestroy vs OnManagedDestroy
Current Usage: Only 1 file uses OnManagedDestroy() (SceneManagerService)
Most files override OnDestroy():
protected override void OnDestroy()
{
base.OnDestroy(); // Must remember this!
// Custom cleanup
}
Problem: The OnManagedDestroy() hook exists but isn't being used as intended.
Recommendation:
- Make
OnDestroy()sealed (force use ofOnManagedDestroy()) - Or deprecate
OnManagedDestroy()entirely (seems redundant)
😕 Confusion Point: SaveId Customization
Default Behavior: "SceneName/GameObjectName/ComponentType"
Comment Says: "Override ONLY for special cases (e.g., singletons like 'PlayerController', or custom IDs)"
Reality: Many components don't realize they need custom SaveIds until save data collides.
Recommendation:
- Add editor validation: Detect duplicate SaveIds and show warnings
- Better yet: Generate GUIDs for components that don't override SaveId
- Document the collision risks more prominently
7. Recommendations Summary
🔴 High Priority (Fix These)
-
Eliminate the
newkeyword pattern:- Make
ManagedBehaviour.Awake()private and non-virtual - Add
protected virtual void OnPreRegister()hook for singletons to set instances - Reduces fragility and removes "CRITICAL" comment dependency
- Make
-
Seal
OnDestroy()or deprecateOnManagedDestroy():- Current dual pattern confuses developers
- Choose one approach and enforce it
-
Fix AutoRegister asymmetry:
- Move unregistration to LifecycleManager for symmetry
- Or remove AutoRegisterPausable entirely (explicit > implicit)
🟡 Medium Priority (Should Do)
-
Replace Invoke wrappers with
internal virtualmethods:- Eliminates 11 one-liner methods
- Cleaner API surface
-
Consolidate priority properties:
- Most components only customize one priority
- Reduce to 2-3 priorities or use attributes
-
Cache SaveId:
- Don't regenerate on every access
- Validate uniqueness in editor
🟢 Low Priority (Nice to Have)
-
Improve developer documentation:
- Add flowchart for lifecycle phases
- Create visual diagram of execution order
- Add common pitfalls section
-
Add editor validation:
- Warn if SaveId collisions detected
- Warn if base.OnDestroy() not called
- Warn if GameManager accessed in Awake()
-
Performance optimization:
- Binary search for InsertSorted (only if profiling shows need)
- Cache priority lookups
8. Final Verdict
What You Asked For:
- ✅ Thorough analysis of the code - Complete
- ✅ Summary of logic and expected behavior - Confirmed correct
- ✅ Problematic code identification - 7 issues found (3 high, 4 medium)
- ✅ Code style improvements - Documentation, regions, naming reviewed
Overall Assessment:
Architecture: ✅ Solid
Implementation: ⚠️ Needs refinement
Developer Experience: ⚠️ Can be improved
The system behaves as expected and provides real value (guaranteed execution order, clean lifecycle hooks, save/load integration). However, there are code smell issues that increase complexity and cognitive load:
- The
newkeyword pattern is fragile - Invoke wrapper bloat
- Dual OnDestroy patterns
- AutoRegister coupling
These are fixable without major refactoring. The core architecture doesn't need to change.
Is It Over-Engineered?
No, but it's close to the line.
- 6 priority properties = probably too granular (most are unused)
- 11 invoke wrappers = definitely unnecessary (use
internal virtual) - AutoRegisterPausable = debatable (saves 1 line of code, adds coupling)
- Batching system = justified (prevents race conditions during scene load)
- Priority-sorted lists = justified (core value proposition)
Tight, Developer-Friendly, Not Over-Engineered Code:
You're 80% there. The fixes I've outlined will get you to 95%. The remaining 5% is personal preference (e.g., regions vs no regions).
Next Steps
Before I implement anything:
- Which of these issues do you want fixed? (All high priority? Some medium?)
- Do you want me to make the changes, or just provide guidance?
- Any architectural decisions you want to discuss first? (e.g., keep or remove AutoRegisterPausable?)
I'm ready to execute once you provide direction. 🚀