When taking over a live Unity game that has been running in production for several years, a familiar scenario unfolds: almost every script you open contains calls like GameManager.Instance, SoundManager.Instance, or DataManager.Instance.
These global Singletons are scattered across hundreds of files. As features pile up, tweaking a single line of code in one system can silently break completely unrelated functionality elsewhere, triggering frustrating regression bugs.
Eventually, the engineering team agrees that automated unit testing must be integrated into the CI/CD pipeline to safeguard mission-critical calculations. But the moment you start writing tests, a massive roadblock appears: core business logic is tightly coupled to GameManager.Instance (which inherits from MonoBehaviour). To test a simple damage formula or reward calculation, you are forced to instantiate GameObjects, load scenes, and spin up an army of unrelated peripheral systems.
At that moment, the first instinct for many developers is: “Should we rewrite the entire architecture from scratch, rip out all Singletons, and replace them with a Dependency Injection framework like VContainer or Zenject?”
In production reality, no Product Owner or Game Producer will pause game releases for a month just so engineers can “clean up code.” The game must ship weekly updates on schedule. You need a pragmatic approach: write solid unit tests for new features today, while systematically containing and paying down legacy technical debt without disrupting release velocity.
Below is a battle-tested 3-level dependency management strategy, chosen based on release deadlines and the allowable blast radius within the legacy codebase.
1. The Core Testing Bottleneck with Singletons
To understand why Singletons resist automated unit testing, consider the requirements of a clean unit test:
- A unit test must be completely isolated and execute quickly (under a few milliseconds).
- When a method directly invokes a static variable like
GameManager.Instance, it conceals a hidden dependency. - Because
GameManageris a MonoBehaviour bound to the Unity Engine lifecycle, standard C# mocking libraries (such as Moq or NSubstitute) cannot mock its behavior inside a pure C# test runner.
flowchart TD
subgraph Problem["Bottleneck in Testing with Singletons"]
Test["Unit Test Runner (CI/CD)"] -->|Calls method under test| Method["CalculateReward()"]
Method -->|Directly calls static variable| GM["GameManager.Instance (MonoBehaviour)"]
GM -->|Engine dependency| Engine["Unity Scene / GameObject Lifecycle"]
GM -->|Pulls heavy resources| Res["SaveSystem / Audio / Network"]
end
The consequence is severe: executing a basic calculation requires bootstrapping an entire mock scene with active GameObjects in memory. This slows test suite execution down to a crawl and causes cascading test failures whenever someone touches GameManager.
To eliminate this bottleneck without halting development, we divide refactoring into three distinct levels.
2. Level 1: Zero-Touch Isolation with Adapters (Zero Touch Legacy)
When to Use
This pattern is ideal when shipping an urgent feature in the current sprint, or when team policy strictly dictates: Do not touch legacy GameManager code under any circumstances to prevent unintended regressions in an upcoming release build.
Implementation Strategy
The core rule is simple: New features are prohibited from calling GameManager.Instance directly within their business logic. Instead, the new feature communicates strictly through an abstract interface, and we introduce an Adapter class as a thin bridge.
flowchart LR
subgraph NewFeature["New Feature (Protected)"]
Logic["CombatRewardCalculator"]
Interface["IPlayerStatsProvider"]
end
subgraph Production["Production Runtime"]
Adapter["GameManagerAdapter"]
Legacy["GameManager.Instance"]
end
subgraph Testing["Unit Test Environment"]
Mock["FakePlayerStatsProvider"]
end
Logic -->|Depends on| Interface
Adapter -.->|implements| Interface
Adapter -->|Calls implicitly| Legacy
Mock -.->|implements| Interface
Logic -.->|Inject test data| Mock
Code Demo: Adapter Wrapper Isolating Singleton
First, the new feature declares precisely the data it needs through a focused interface:
// 1. Interface contains only data required by the new feature
public interface IPlayerStatsProvider
{
int CurrentLevel { get; }
int BaseGold { get; }
}
The business logic accepts this interface through its constructor:
// 2. Pure C# business logic with zero Unity Engine dependencies
public class CombatRewardCalculator
{
private readonly IPlayerStatsProvider _statsProvider;
public CombatRewardCalculator(IPlayerStatsProvider statsProvider)
{
_statsProvider = statsProvider;
}
public int CalculateGoldReward(int enemyBounty)
{
int multiplier = Math.Max(1, _statsProvider.CurrentLevel);
return (_statsProvider.BaseGold + enemyBounty) * multiplier;
}
}
In the production runtime environment, an Adapter wraps GameManager.Instance:
// 3. Adapter acts as an Anti-Corruption Layer
public class GameManagerStatsAdapter : IPlayerStatsProvider
{
public int CurrentLevel => GameManager.Instance.PlayerLevel;
public int BaseGold => GameManager.Instance.DefaultGoldBonus;
}
Writing Unit Tests at Level 1
Inside the test suite, there is zero reliance on GameManager or Unity scene lifecycles:
using NUnit.Framework;
[TestFixture]
public class CombatRewardCalculatorTests
{
private class FakePlayerStats : IPlayerStatsProvider
{
public int CurrentLevel { get; set; }
public int BaseGold { get; set; }
}
[Test]
public void CalculateGoldReward_Level2_ReturnsDoubledReward()
{
var fakeStats = new FakePlayerStats { CurrentLevel = 2, BaseGold = 100 };
var calculator = new CombatRewardCalculator(fakeStats);
int result = calculator.CalculateGoldReward(enemyBounty: 50);
// (100 + 50) * 2 = 300
Assert.AreEqual(300, result);
}
}
Trade-Off Analysis
- Pros: 100% risk-free. Not a single line of legacy
GameManagercode is modified. New logic is thoroughly tested and completely decoupled from engine lifecycles. - Cons: Introduces an extra Adapter layer. If applied indiscriminately without an eventual cleanup plan, the codebase will maintain two parallel paradigms: one using Singletons and another using Adapters. Treat this as an immediate stopgap to prevent technical debt from infecting new features.
3. Level 2: Extracting Capability-Focused Interfaces (Minimal Singleton Touch)
When to Use
Suitable when the feature has moderate complexity, the shared data will be reused across multiple upcoming gameplay modules, and the team agrees to minor, non-breaking modifications to GameManager.cs.
Implementation Strategy
Rather than creating an enormous IGameManager interface (which blatantly violates the Interface Segregation Principle), identify the exact capability required by the new feature.
For example, if a combat system requires enemy defense parameters, declare an IEnemyStatsProvider interface. Then, have GameManager implement that interface directly without touching its internal state or existing APIs.
flowchart TD
subgraph LegacySingleton["Legacy God Class (GameManager.cs)"]
GM["GameManager"]
end
subgraph Capabilities["Capability-focused Interfaces"]
IEnemy["IEnemyStatsProvider"]
IScore["IScoreManager"]
end
GM -.->|implements| IEnemy
GM -.->|implements| IScore
LegacyCode["Hundreds of legacy scripts"] -->|Continue calling directly| GM
NewCombatFeature["New Combat System"] -->|Only depends on| IEnemy
Code Demo: Extracting Capability Interfaces
Inside GameManager.cs, implement the new interface while preserving all existing logic:
public interface IEnemyStatsProvider
{
int Defense { get; }
float DamageReductionRate { get; }
}
// GameManager implements the focused interface
public class GameManager : MonoBehaviour, IEnemyStatsProvider
{
public static GameManager Instance { get; private set; }
[SerializeField] private int enemyDefense = 20;
[SerializeField] private float damageReduction = 0.1f;
public int Defense => enemyDefense;
public float DamageReductionRate => damageReduction;
private void Awake()
{
if (Instance == null) Instance = this;
else Destroy(gameObject);
}
// Existing legacy methods remain untouched...
public void OldLegacyMethod() { }
}
In the new component, inject the dependency via an Initialize() method:
public class DamageSystem : MonoBehaviour
{
private IEnemyStatsProvider _statsProvider;
public void Initialize(IEnemyStatsProvider statsProvider)
{
_statsProvider = statsProvider;
}
public int ComputeDamage(int rawAttack)
{
if (_statsProvider == null) return rawAttack;
return (int)(rawAttack * (1f - _statsProvider.DamageReductionRate)) - _statsProvider.Defense;
}
}
In your Unity scene bootstrap script (or within GameManager itself), pass the reference during startup:
damageSystem.Initialize(GameManager.Instance);
Trade-Off Analysis
- Pros: Legacy callers continue using
GameManager.Instancewithout change. New code depends solely on clean interfaces, making unit testing straightforward with mocks. Avoids creating extra adapter wrapper files. - Cons:
GameManagerfundamentally remains a God Class. If every developer appends a new interface toGameManagerfor every new task, it can quickly end up implementing 15+ interfaces. This is an effective transitional stepping stone, but not a final architectural state.
4. Level 3: Service Extraction & Turning GameManager into a Facade (Controlled Refactoring)
When to Use
Ideal when feature development directly impacts core game loops (Score, Save/Load, Inventory, Stage Lifecycle), or during dedicated stabilization/refactoring sprints where paying down technical debt is an explicit goal.
Implementation Strategy
Instead of attempting to delete GameManager overnight, transform it from an all-knowing God Class into a thin Facade Coordinator:
- Group cohesive data and logic into dedicated pure C# services (
ScoreService,InventoryService,LevelService). - Make these services 100% testable in pure C# without MonoBehaviour baggage.
GameManagerholds references to these services and delegates incoming requests to them.- For hundreds of legacy scripts still invoking
GameManager.Instance.AddScore(10), everything continues working seamlessly becauseGameManagerstill exposes that method, delegating the call internally to_scoreService.AddScore(10).
flowchart TD
subgraph FacadePattern["GameManager Refactored to Facade Coordinator"]
GM["GameManager.Instance (MonoBehaviour)"]
end
subgraph PureServices["Pure C# Services (Fully Testable)"]
ScoreSvc["ScoreService (Pure C#)"]
InventorySvc["InventoryService (Pure C#)"]
StatsSvc["PlayerStatsService (Pure C#)"]
end
GM -->|Owns & coordinates| ScoreSvc
GM -->|Owns & coordinates| InventorySvc
GM -->|Owns & coordinates| StatsSvc
LegacyCode["Legacy project scripts"] -->|"Calls GameManager.Instance.AddScore()"| GM
NewCode["Future new features"] -->|Inject directly| ScoreSvc
Code Demo: Service Extraction and Facade Delegation
Extract score management into a pure C# class:
// 1. Pure C# service with full testability and fast execution
public class ScoreService
{
public int CurrentScore { get; private set; }
public event Action<int> OnScoreChanged;
public void AddScore(int amount)
{
if (amount <= 0) return;
CurrentScore += amount;
OnScoreChanged?.Invoke(CurrentScore);
}
public void Reset()
{
CurrentScore = 0;
OnScoreChanged?.Invoke(CurrentScore);
}
}
Refactor GameManager into a delegating Facade:
// 2. GameManager acts as a Facade maintaining backwards compatibility
public class GameManager : MonoBehaviour
{
public static GameManager Instance { get; private set; }
// Pure C# sub-services
public ScoreService ScoreService { get; private set; }
private void Awake()
{
if (Instance == null)
{
Instance = this;
InitializeServices();
}
else
{
Destroy(gameObject);
}
}
private void InitializeServices()
{
ScoreService = new ScoreService();
}
// Backwards-compatible API for legacy callers:
// Existing code calling GameManager.Instance.AddScore() remains fully operational
public void AddScore(int amount)
{
ScoreService.AddScore(amount);
}
public int GetCurrentScore()
{
return ScoreService.CurrentScore;
}
}
Trade-Off Analysis
- Pros: The cleanest long-term architectural solution. Core gameplay logic migrates to pure C# domain classes with sub-millisecond test execution. Zero regressions for existing code because the public API remains 100% backwards compatible.
- Cons: Demands planning, rigorous test coverage prior to extraction, and a solid understanding of data flow across the entire project.
5. Trade-Off Comparison Across All 3 Levels
No single approach fits every development timeline. Engineering maturity is about selecting the right solution for your team’s current constraints:
| Metric | Level 1: Adapter / Wrapper | Level 2: Capability Interface | Level 3: Service Facade |
|---|---|---|---|
| Intervention in Legacy Singleton | Zero modifications | Minor interface addition | Extracts logic into pure services |
| Unit Test Feasibility | Excellent for new feature | Excellent for new feature | Outstanding for both old & new logic |
| Turnaround Time | Very fast (a few hours) | Fast (half a day) | Requires a few days of planning & tests |
| Blast Radius on Existing Code | Zero risk | Very low risk | Requires careful regression testing |
| Technical Debt Elimination | Temporary stopgap | Moderate improvement | Substantial architectural transformation |
| Recommended Scenario | Tight deadlines, hotfixes, small features | New features sharing common state | Dedicated refactor sprints, core loop changes |
6. Practical Roadmap: The Boy Scout Rule
When inheriting a legacy Unity project riddled with Singletons, resist the urge to open a massive pull request rewriting thousands of lines of code over a weekend. That route almost always results in merge conflicts, broken releases, and elusive regressions.
Instead, adhere to the Boy Scout Rule:
“Always leave the campground cleaner than you found it.”
- When adding new features: Do not add any new
.Instanceinvocations. Use Level 1 or Level 2 to protect new domain logic with unit tests from day one. - When fixing bugs in legacy code: Extract the faulty calculation into a pure C# class, write unit tests to reproduce and fix the bug, then hook the class back into the legacy workflow.
- When architectural maintenance time is available: Systematically decompose
GameManagerresponsibilities into focused pure C# services using Level 3.
By taking disciplined, incremental steps, you maintain weekly release velocity while progressively transforming a chaotic legacy codebase into a clean, test-driven, modular architecture.