Unity Dependency injection

Untangling Your Spaghetti Code: A Deep Dive into Dependency Injection in Unity

Stop using GetComponent and FindObjectOfType everywhere. Learn the architectural pattern that makes your games scalable, testable, and sane.

Introduction: The Unity “House of Cards”

If you’ve been developing in Unity for any length of time, you know the feeling. You start a project, and everything is smooth. Your Player holds a reference to the Sword script. The Sword script grabs the Audio Manager to play a “swish” sound. The Audio Manager needs to check the Game Settings UI to see if the volume is up.

Suddenly, your project looks like a bowl of spaghetti.

You want to change how swords work, but doing so breaks the audio manager. You want to test the player movement, but you can’t run the scene without loading the entire UI system first.

You have built a house of cards. One wrong move, and the architecture collapses.

This is the problem of Tight Coupling. The solution? Dependency Injection (DI).

In this deep dive, we are going to move past the buzzwords and look at practical implementations of DI in Unity, from manual approaches to industry-standard frameworks like Zenject (Extenject).


What is a “Dependency” anyway?

Before we inject anything, let’s define the problem.

In programming, if Class A needs Class B to do its job, Class B is a dependency of Class A.

In Unity terms: If your PlayerController needs your InventoryManager to check for potions, the InventoryManager is a dependency.

The “Old School” Unity Way (The Problem)

Traditionally, Unity developers resolve dependencies like this:

  1. Dragging and Dropping in the Inspector: (Fragile; breaks if prefabs lose references).
  2. GetComponent / FindObjectOfType: (Slow; relies on scene structure; hard to manage).
  3. Singletons: (The most common trap. Makes testing nearly impossible and hides dependencies globally).

Here is an example of Tight Coupling in code. We have a Player who needs a weapon to attack.

// THE "BAD" WAY: TIGHT COUPLING
public class FireSword
{
    public void Swing() { Debug.Log("Swish with fire particles!"); }
}

public class Player : MonoBehaviour
{
    // The Player creates its own dependency. This is tight coupling.
    private FireSword mySword = new FireSword();

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            mySword.Swing();
        }
    }
}

Why is this bad? The Player class is married to the FireSword class. If I want to give the player an IceHammer later, I have to rewrite the Player class. I cannot test the Player without also testing the FireSword.

We can visualize this tight coupling with a diagram:

Unity Dependency injection

Enter Dependency Injection: The Solution

Dependency Injection is a fancy term for a very simple concept:

Instead of a class creating or finding the things it needs, those things should be given (injected) into it.

Think of it like a power drill. You don’t buy a drill with one permanent drill bit welded onto it. You have a “chuck” (an interface) that accepts any bit that fits the specification. You “inject” the bit you need for the current job.

The Golden Rule: Code against Interfaces

To make DI work, we need to stop relying on concrete classes (like FireSword) and rely on abstractions (Interfaces).

Let’s fix our previous example.

Step 1: The Interface We define what a weapon is.

public interface IWeapon
{
    void Swing();
}

Step 2: The Concrete Implementations We create weapons that satisfy the interface.

public class FireSword : IWeapon
{
    public void Swing() { Debug.Log("Swish with fire particles!"); }
}

public class IceHammer : IWeapon
{
    public void Swing() { Debug.Log("Bonk with ice shards!"); }
}

Step 3: The Player (Refactored) The Player no longer cares what weapon it has, as long as it follows the IWeapon rules.

public class Player : MonoBehaviour
{
    // I don't know what this is, but I know it can Swing().
    private IWeapon currentWeapon;

    // Dependency Injection happens here!
    // Something external must call this method to give the player a weapon.
    public void SetWeapon(IWeapon weapon)
    {
        this.currentWeapon = weapon;
    }

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space) && currentWeapon != null)
        {
            currentWeapon.Swing();
        }
    }
}

This is Loose Coupling. The Player is decoupled from the specific weapon implementation.

Let’s look at how the architecture has changed:

Unity Dependency injection

How to actually “Inject” dependencies in Unity

So, the Player class is ready to receive a weapon. But who gives it to them? Who is the “injector”?

There are two main ways to handle this in Unity: Manual Injection (poor man’s DI) and using a DI Container (Frameworks).

1. Manual Dependency Injection

For small projects, you can do this yourself using a “Bootstrapper” or “GameManager” script. This script runs first, creates all the necessary parts, and connects them.

Note: Standard C# constructors don’t work well with MonoBehaviours because Unity manages their lifecycle. We often use an Init method instead.

// The "God Class" that sets everything up
public class GameBootstrapper : MonoBehaviour
{
    public Player playerInstanceInScene;

    void Awake()
    {
        // 1. Create the dependencies
        IWeapon startingWeapon = new IceHammer();

        // 2. Inject the dependency into the dependent class
        playerInstanceInScene.SetWeapon(startingWeapon);

        Debug.Log("Game initialized and weapon injected!");
    }
}

Pros: Simple to understand, no external libraries. Cons: Becomes incredibly tedious as your project grows. You end up with massive Bootstrapper files managing hundreds of connections.


2. Automated Injection: Introducing Zenject (Extenject)

As your game scales, manual injection becomes unmanageable. This is where DI Frameworks come in. The industry standard for Unity is Zenject (currently maintained as Extenject on the Asset Store).

Zenject acts as the glue automatically. You tell Zenject once: “Whenever anyone asks for an IWeapon, give them a FireSword.” Zenject handles the rest.

Here is the same example rewritten using Zenject.

The Player Class (Zenject Style)

We no longer need manual setup methods. We use the [Inject] attribute.

using UnityEngine;
using Zenject; // Import the library

public class Player : MonoBehaviour
{
    // Zenject will magically fill this variable before Start() runs.
    [Inject]
    private IWeapon currentWeapon;

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            currentWeapon.Swing();
        }
    }
}

The Setup (The MonoInstaller)

In Zenject, you configure your bindings in a special script called an Installer. You add this installer to your scene context.

using Zenject;

public class GameInstaller : MonoInstaller
{
    // This is the configuration map.
    public override void InstallBindings()
    {
        // "Container, whenever a script asks for an IWeapon,
        // create a new instance of FireSword and give it to them."
        Container.Bind<IWeapon>().To<FireSword>().AsTransient();
    }
}

Why is this magical?

If you decide later that the starting weapon should be the Ice Hammer, you change one line of code in the Installer:

Container.Bind<IWeapon>().To<IceHammer>().AsTransient();

Every class in your game that uses [Inject] private IWeapon weapon; will now automatically receive the Ice Hammer. You don’t have to touch the Player script at all.


Real-World Use Cases in Game Development

DI isn’t just for weapons. It shines in almost every major game system.

Use Case 1: The Input System

The Problem: You hardcode Input.GetKey(KeyCode.Space) in your player jump script. The Issue: You want to port the game to consoles. Now you have to find every reference to Input.GetKey and add controller support logic. It’s a nightmare.

The DI Solution:

  1. Create an interface IInputProvider.
public interface IInputProvider {
    bool IsJumpPressed();
    Vector2 GetMovementInput();
}
  1. Create concrete classes: KeyboardInput : IInputProvider and XboxControllerInput : IInputProvider.
  2. Inject IInputProvider into your PlayerController.
  3. Use an Installer to bind the correct input scheme based on the platform you are deploying to.

Use Case 2: Save/Load Systems and Analytics

The Problem: Your game logic directly calls PlayerPrefs.SetInt("Score", 10). The Issue: You want to switch to binary serialization, or save to the cloud (Steam Cloud/PlayFab). You have to rewrite code everywhere saved data is touched.

The DI Solution: Define an ISaveSystem interface with methods like SaveData(string key, object data) and LoadData(string key). Your game logic only talks to the interface. You can swap out PlayerPrefsSaveSystem for CloudSaveSystem in the Installer without breaking game logic.

Same goes for analytics. Inject an IAnalyticsService. Sometimes it logs to Unity Analytics, sometimes it logs to a mock console output for debugging.

Use Case 3: Unit Testing (The Holy Grail)

This is the biggest benefit of DI. How do you test if your Player takes damage correctly without loading the entire game scene with enemies?

With DI, you can create “Mock” objects.

// A fake weapon just for testing
public class MockWeapon : IWeapon
{
    public bool UsuallySwings = false;
    public void Swing() { UsuallySwings = true; }
}

[Test]
public void TestPlayerAttack()
{
    Player player = new Player();
    MockWeapon mockWeapon = new MockWeapon();

    // Manually inject the fake weapon
    player.SetWeapon(mockWeapon);

    // Simulate input/call attack method
    player.ForceAttack();

    // Assert that the weapon was actually used
    Assert.IsTrue(mockWeapon.UsuallySwings);
}

You have now verified Player attack logic without running Unity, without graphics, and in milliseconds.


Conclusion

Dependency Injection can feel like over-engineering at first. It requires more upfront setup—creating interfaces and installers seems like extra work compared to just drag-and-dropping a reference in the Inspector.

But as your project grows from a prototype into a production game, that upfront investment pays massive dividends. It turns a fragile house of cards into a modular set of LEGO bricks. You can swap pieces, test them individually, and scale your game without fear of the entire structure collapsing.

Start small. Try refactoring just one system in your current project (like Audio or Input) to use an interface and manual injection. Once you feel the power of decoupling, you’ll never want to go back to spaghetti code again.

Leave a Reply

Your email address will not be published. Required fields are marked *