Dependency injection is one of those topics that sounds bigger than it is.

At the useful end of the scale, it is just this: build things at the edge of the game, then pass them into the code that needs them. Do not make your gameplay code go hunting through globals to find its own tools.

That sounds almost too simple, which is usually a good sign.

Dependency Injection For Game Code diagram

What DI is actually doing

Dependency injection is about separating configuration from use.

A class should say what it needs. Something else should decide how to build those things and where they come from.

That is the core idea.

In a game, the thing doing the wiring is usually a bootstrapper, installer, scene initializer, or composition root. It creates the concrete services, then passes them into the systems that need them.

The gameplay code stays focused on gameplay.

Why this matters in games

Game projects accumulate dependencies quickly.

A player controller may need:

  • input
  • audio
  • animation
  • save data
  • inventory
  • targeting
  • camera control

If every class reaches into a singleton or service locator, the code becomes hard to reason about.

You lose two things at once:

  1. Clarity — you cannot see what a class needs just by looking at its constructor or setup.
  2. Testability — you cannot easily swap in a fake input system or fake audio sink.

The result is code that is easy to call and hard to change.

A small example

Say you have a weapon system.

A bad version builds its own dependencies:

public sealed class WeaponFire
{
    public void Fire()
    {
        var audio = AudioSystem.Instance;
        var rng = RandomService.Instance;
        var projectiles = ProjectileDatabase.Instance;

        audio.Play("gunshot");
        projectiles.Spawn(rng.NextProjectile());
    }
}

That works, until it does not.

Now the class knows where audio comes from, where randomness comes from, and where projectiles come from. It is no longer just a weapon action. It is tied to your whole runtime.

A better version asks for what it needs:

public interface IAudio
{
    void Play(string cue);
}

public interface IRng
{
    int Next(int max);
}

public interface IProjectileFactory
{
    void Spawn(int index);
}

public sealed class WeaponFire
{
    private readonly IAudio audio;
    private readonly IRng rng;
    private readonly IProjectileFactory projectiles;

    public WeaponFire(IAudio audio, IRng rng, IProjectileFactory projectiles)
    {
        this.audio = audio;
        this.rng = rng;
        this.projectiles = projectiles;
    }

    public void Fire()
    {
        audio.Play("gunshot");
        projectiles.Spawn(rng.Next(4));
    }
}

Now the class reads like a contract.

If you want to test it, you can pass fake implementations. If you want to swap the audio backend, you change the wiring, not the weapon logic.

The composition root is the real center

The cleanest DI setups have one place where the object graph gets assembled.

That place is the composition root.

In a game, that might be:

  • the first scene loader
  • a bootstrapper object
  • a game mode initializer
  • a DI container installer

That is where you create the concrete services:

  • WwiseAudio or UnityAudio
  • InputSystem or GamepadInput
  • JsonSaveService
  • WeaponFactory
  • EnemySpawner

Then you hand those services to the systems that use them.

The important part is that the wiring stays at the edge.

Gameplay code should not need to know whether the audio service comes from a singleton, a framework, or a plain new call in a bootstrapper.

Unity changes the shape, not the rule

Unity adds one annoying constraint: MonoBehaviour is not a normal class.

Unity’s own manual warns that if you try to define a constructor for a MonoBehaviour, it can interfere with normal operation. So if you are working in Unity, do not force constructor injection onto every component and pretend the engine does not exist.

Use DI where it fits naturally.

A good split is:

  • plain C# classes get constructor injection
  • Unity components get wired through bootstrap code, serialized references, or explicit setup methods
  • engine-facing code stays thin and hands work off to testable classes

That keeps the engine boundary honest.

If your MonoBehaviour is doing all the real work, you are making your life harder than it needs to be.

DI is not the same as service locator

People mix these up all the time.

A service locator says, “Ask for whatever you want, whenever you want.”

Dependency injection says, “You get what you need up front.”

That difference matters.

Service locator can be useful for truly ambient services, but it hides the dependency from the class that uses it. The code looks simpler while becoming more implicit.

DI keeps the dependency visible.

That visibility is the point.

If a class needs input, let it say so. If it needs audio, let it say so. If it needs save data, let it say so.

That is less magical, which is exactly why it works better.

What not to overbuild

You do not need a framework to do dependency injection.

That is the mistake people make first.

They hear “DI” and reach for a container before they have even written a constructor.

Start smaller:

  • pass dependencies into plain classes
  • use interfaces where there is a real seam
  • wire the graph once at startup
  • avoid globals unless the service is truly ambient

If the project grows, a container may become worth it. If it does not, you have still kept the code sane.

Interfaces are not free either. Do not add one for every class out of habit. Add a seam when you actually need one: a second implementation, a test double, or a boundary you want to keep clean.

Practical rule of thumb

Use dependency injection when:

  • a class needs stable collaborators
  • you want to test the class without the engine
  • you want to swap implementations by configuration
  • you want the object graph to be explicit

Skip it when:

  • the dependency is truly local and trivial
  • the class is a one-off glue object
  • the extra indirection buys you nothing

That is the whole tradeoff.

Practical takeaway

Dependency injection is not about being fancy.

It is about keeping game code honest about what it needs.

When dependencies are wired at the edge, gameplay code becomes easier to read, easier to test, and easier to swap out later. That is especially valuable in games, where systems tend to grow sideways until the dependencies become the real problem.

Keep the wiring at the edge. Keep the behavior in the middle.

References