Skip to content
ToanLB
Go back

Why Do Large Projects Avoid the Singleton Pattern in Unity?

In the early days of game development with Unity, or when rushing to build a quick prototype before a tight deadline, typing .Instance feels like second nature. Need sound effects? Call SoundManager.Instance.Play(). Need to add score? Call GameManager.Instance.AddScore(). Need player health? Call PlayerData.Instance.Hp.

At the beginning, this approach feels remarkably productive: no dragging references across the Inspector, no passing dependencies through constructors, and instant global access from any script in the codebase.

However, as the project scales into a multi-year live-service production with dozens of levels, complex gameplay systems, and multiple engineers collaborating, this convenience compounds into heavy technical debt. RAM steadily climbs after each scene transition, NullReferenceException crashes appear intermittently during initialization, and writing automated unit tests becomes nearly impossible.

This article breaks down the root issues behind overusing Singletons in Unity—from low-level runtime memory management and the engine lifecycle to software architecture—and shares practical modular refactoring strategies for growing codebases.


1. Low-Level Mechanics: C# Runtime and Unity Lifecycle

To understand why Singletons frequently turn into technical bottlenecks, we need to inspect how the C# Runtime (Mono/IL2CPP) and Unity Engine interact to manage memory and object lifecycles.

flowchart TD
    subgraph CSharpRuntime["C# Runtime (Mono / IL2CPP)"]
        GCRoot["Static GC Root (Instance)"] -->|Permanently holds reference| SingObj["Singleton Instance (Heap)"]
        SingObj -->|Holds reference| HeavyRes["AudioClips / Textures / Scene Objects"]
    end

    subgraph UnityLifecycle["Unity Lifecycle"]
        A["Scene Unload"] -.->|Cannot be collected| HeavyRes
        B["Awake() Race Condition"] -->|A calls B before B is awake| NullEx["Intermittent NullReferenceException"]
        C["OnDestroy() calls Instance"] -->|Lazy loading spawns duplicate| Zombie["Zombie Object across Scenes"]
    end

Static Variables Act as GC Roots Causing Memory Leaks

In the C# runtime, a static field (such as _instance) lives for the entire lifetime of the Application Domain and is treated by the Garbage Collector as an immortal GC Root. The garbage collector traverses the reference graph starting from GC Roots and will never collect any object reachable from them.

When a MonoBehaviour Singleton directly holds references to heavy assets like AudioClip, Texture, or registers callbacks to C# events on scene GameObjects without unregistering on scene changes:

Initialization Race Conditions Across MonoBehaviours

Unity provides no guarantee regarding the execution order of Awake() across different GameObjects in a scene unless manually configured in Script Execution Order (which becomes unmaintainable as projects expand).

If SingletonA.Awake() depends on and calls SingletonB.Instance, but B’s GameObject hasn’t executed Awake() yet during that frame, an elusive NullReferenceException occurs. These bugs are notoriously difficult to reproduce because they depend on arbitrary scene serialization order.

The “Zombie Object” Phenomenon During Scene Unloads

When a scene unloads, the engine destroys GameObjects in that scene. If a script in its OnDestroy() method attempts to access GameManager.Instance.SaveData(), and the Singleton implements lazy initialization (if (_instance == null) new GameObject(...)):


2. Architectural Bottlenecks at Scale

Beyond engine lifecycle quirks, Singletons introduce major architectural hurdles when working in engineering teams.

flowchart TD
    subgraph Coupling["Tight Coupling (Hidden Dependencies)"]
        UI["UIController"] -->|Calls directly| SM["SoundManager.Instance"]
        SM -->|Hidden dependency| DM["DataManager.Instance"]
        DM -->|Hidden dependency| CF["Config.Instance"]
        Combat["CombatController"] -->|Hard coupled| GM["GameManager.Instance"]
    end

    subgraph StateMutation["Global State (Loss of Data Ownership)"]
        Enemy["EnemyController"] -->|Mutates directly| Score["GameManager.score"]
        Quest["QuestManager"] -->|Mutates directly| Score
        Combo["ComboSystem"] -->|Mutates directly| Score
    end

Hidden Dependencies and Reusability Friction

Inspecting a class’s public interface or constructor often gives the false impression that the module is self-contained. In reality, methods throughout the class may be directly calling four or five global Singletons.

This creates a domino effect:

Testing and Mocking Barriers

Suppose combat damage calculation (Damage = Attack - Defense) is pure business logic that should execute in under a microsecond.

If that calculation method directly calls GameManager.Instance.CurrentEnemyDefense:


3. Code Demo: Before and After Refactoring

Let’s look at a concrete example of decoupling a combat controller (CombatController).

Before: Convenient but tightly coupled

using UnityEngine;

public class CombatController : MonoBehaviour
{
    public void DealDamage(int baseDamage)
    {
        // 1. Hidden dependency on GameManager for defense stats
        int defense = GameManager.Instance.CurrentEnemyDefense;
        int finalDamage = Mathf.Max(1, baseDamage - defense);

        // 2. Direct call to SoundManager
        SoundManager.Instance.PlaySfx("Hit_Sound");

        // 3. Uncontrolled global state mutation
        GameManager.Instance.PlayerScore += finalDamage * 10;
        
        Debug.Log($"Dealt {finalDamage} damage.");
    }
}

The method above cannot be unit tested without a running Unity Scene and global singletons.


After: Decoupled Core Logic with Dependency Injection

We decouple the system into 3 clean layers:

  1. Extract mathematical calculations into a pure C# class (no MonoBehaviour inheritance).
  2. Define explicit abstractions (interfaces) for audio and score tracking.
  3. Pass dependencies explicitly via constructor or an Initialize method.
using System;
using UnityEngine;

// 1. Interfaces representing distinct capabilities
public interface IEnemyStatsProvider
{
    int Defense { get; }
}

public interface IAudioService
{
    void PlaySfx(string soundName);
}

public interface IScoreTracker
{
    void AddScore(int amount);
}

// 2. Core business logic in pure C# — 100% testable without Unity
public class DamageCalculator
{
    public int CalculateDamage(int baseAttack, int targetDefense)
    {
        return Math.Max(1, baseAttack - targetDefense);
    }
}

// 3. Controller receives explicit dependencies from outside
public class CombatController : MonoBehaviour
{
    private DamageCalculator _damageCalculator;
    private IEnemyStatsProvider _enemyStats;
    private IAudioService _audioService;
    private IScoreTracker _scoreTracker;

    public void Initialize(
        DamageCalculator damageCalculator,
        IEnemyStatsProvider enemyStats,
        IAudioService audioService,
        IScoreTracker scoreTracker)
    {
        _damageCalculator = damageCalculator;
        _enemyStats = enemyStats;
        _audioService = audioService;
        _scoreTracker = scoreTracker;
    }

    public void DealDamage(int baseDamage)
    {
        if (_damageCalculator == null || _enemyStats == null) return;

        int finalDamage = _damageCalculator.CalculateDamage(baseDamage, _enemyStats.Defense);

        _audioService?.PlaySfx("Hit_Sound");
        _scoreTracker?.AddScore(finalDamage * 10);
    }
}
flowchart LR
    subgraph CoreLogic["Core Logic (Pure C#)"]
        DC["DamageCalculator"]
    end

    subgraph Abstractions["Interfaces"]
        IStats["IEnemyStatsProvider"]
        IAudio["IAudioService"]
        IScore["IScoreTracker"]
    end

    subgraph Implementations["Concrete Implementations"]
        EnemyMono["EnemyStats (MonoBehaviour)"] -.->|implements| IStats
        AudioMgr["SoundManager (Service)"] -.->|implements| IAudio
        ScoreMgr["ScoreManager (Service)"] -.->|implements| IScore
    end

    Combat["CombatController"] -->|Calls directly| DC
    Combat -->|Calls via interface| IStats
    Combat -->|Calls via interface| IAudio
    Combat -->|Calls via interface| IScore

Writing Blazing-Fast Unit Tests

With business logic isolated in pure C#, tests run in memory in less than a millisecond:

using NUnit.Framework;

[TestFixture]
public class DamageCalculatorTests
{
    [Test]
    public void CalculateDamage_AttackHigherThanDefense_ReturnsDifference()
    {
        var calculator = new DamageCalculator();
        int result = calculator.CalculateDamage(baseAttack: 50, targetDefense: 20);
        Assert.AreEqual(30, result);
    }

    [Test]
    public void CalculateDamage_DefenseHigherThanAttack_ReturnsMinimumOne()
    {
        var calculator = new DamageCalculator();
        int result = calculator.CalculateDamage(baseAttack: 10, targetDefense: 50);
        Assert.AreEqual(1, result);
    }
}

4. Trade-off Matrix

MetricMonoBehaviour SingletonPure C# Service + DIScriptableObject Architecture
Initial Setup SpeedExtremely fast (5 minutes)Moderate (requires interfaces)Fast (Inspector-driven)
Unit TestabilityVery poorExcellentGood
Memory Leak RiskHigh (static GC root)Low (managed lifecycle)Low (asset-bound)
Engine CouplingHigh (MonoBehaviour)Low (Pure C#)Moderate (Unity Object)
Best Suited ForFast prototypes, game jamsMedium-to-large productionModular data & event channels

5. Summary

The Singleton pattern itself is not inherently “evil”—it was designed to solve a very specific problem: ensuring a single instance exists and providing a global access point.

However, in Unity development:

By separating core calculation logic into pure C# classes, passing dependencies via interfaces, and managing lifecycles cleanly through Dependency Injection (such as VContainer) or ScriptableObject architecture, your team can maintain rapid development pace while keeping the codebase robust and regression-free.


Share this post:

If this article brings value to your work, feel free to buy me a coffee here.

Next Post
How to Write Unit Tests for a Legacy Unity Project Full of Singletons (Without a Total Rewrite)

Get New Posts via Email

Subscribe to get notified whenever a new technical deep dive on Unity, C#, and software engineering is published.

* No spam. You can unsubscribe at any time with a single click.