What Is It?
Dependency Injection (DI) flips the usual direction of control. Rather than a Player calling FindObjectOfType<AudioManager>() or new FileSaveService(), it simply declares what it needs — an ILogger, an IAudioService, an ISaveService — and something else supplies them.
That "something else" is the composition root: one place, often a GameInstaller MonoBehaviour in the boot scene, that decides which concrete class backs each interface and passes them in through a constructor or an Inject(...) method. Everything downstream only ever sees interfaces.
The payoff is that swapping behaviour becomes a one-line change in the installer. Ship with CloudSaveService, develop against FileSaveService, and run play-mode tests with a SilentAudioService — the Player class never changes and never knows.
When Is It Used?
Reach for it when a class depends on services that have more than one plausible implementation: platform-specific saving, real vs. mocked analytics, online vs. offline backends.
It is a strong fit for anything you want to unit test. Plain C# classes that receive interfaces through their constructor can be tested in Edit Mode without loading a scene.
For a tiny jam game with three scripts, a full DI setup is overhead. Start with serialized references and introduce an installer once the wiring starts to sprawl across scenes.
Interactive Demo
Click the controls and watch the objects collaborate. The console mirrors what the C# code below would log.
Code
using UnityEngine;
namespace Patterns.Architectural.DependencyInjection
{
/// <summary>
/// The composition root. It is the only place that knows which concrete
/// services exist; everything else receives interfaces.
/// </summary>
[DefaultExecutionOrder(-100)]
public class GameInstaller : MonoBehaviour
{
public enum SaveBackend { File, Cloud }
[SerializeField] private Player player;
[SerializeField] private SaveBackend saveBackend = SaveBackend.File;
[SerializeField] private bool muteAudioForTests;
private void Awake()
{
if (player == null)
{
Debug.LogError("GameInstaller: Player reference is missing.", this);
return;
}
ILogger logger = new UnityConsoleLogger();
IAudioService audio = muteAudioForTests
? new SilentAudioService()
: new UnityAudioService(GetComponent<AudioSource>());
ISaveService save = saveBackend == SaveBackend.Cloud
? new CloudSaveService(logger)
: new FileSaveService(logger);
// Method injection: MonoBehaviours cannot use constructors.
player.Inject(logger, audio, save);
}
}
}using UnityEngine;
namespace Patterns.Architectural.DependencyInjection
{
/// <summary>
/// A consumer that only knows about interfaces. It never creates or
/// searches for its services — they are handed to it.
/// </summary>
public class Player : MonoBehaviour
{
private ILogger logger;
private IAudioService audio;
private ISaveService save;
public int Gold { get; private set; }
public void Inject(ILogger logger, IAudioService audio, ISaveService save)
{
this.logger = logger;
this.audio = audio;
this.save = save;
logger.Log($"Player injected with {save.GetType().Name}");
}
public void CollectCoin()
{
Gold++;
audio.Play("coin");
}
public void Save()
{
if (save == null)
{
Debug.LogError("Player.Save called before Inject.", this);
return;
}
save.Write("gold", Gold.ToString());
audio.Play("save");
logger.Log($"Saved {Gold} gold");
}
}
}namespace Patterns.Architectural.DependencyInjection
{
/// <summary>Writes diagnostic messages somewhere.</summary>
public interface ILogger
{
void Log(string message);
}
/// <summary>Plays named sound effects.</summary>
public interface IAudioService
{
void Play(string clipId);
}
/// <summary>Persists simple key/value data.</summary>
public interface ISaveService
{
void Write(string key, string value);
string Read(string key, string fallback = "");
}
}using System.IO;
using UnityEngine;
namespace Patterns.Architectural.DependencyInjection
{
/// <summary>Logger that forwards to the Unity console.</summary>
public class UnityConsoleLogger : ILogger
{
public void Log(string message) => Debug.Log($"[Game] {message}");
}
/// <summary>Real audio backed by an AudioSource and Resources clips.</summary>
public class UnityAudioService : IAudioService
{
private readonly AudioSource source;
public UnityAudioService(AudioSource source) => this.source = source;
public void Play(string clipId)
{
var clip = Resources.Load<AudioClip>($"Audio/{clipId}");
if (source != null && clip != null) source.PlayOneShot(clip);
}
}
/// <summary>Does nothing. Perfect for tests and headless builds.</summary>
public class SilentAudioService : IAudioService
{
public void Play(string clipId) { }
}
/// <summary>Saves each key to a file in persistentDataPath.</summary>
public class FileSaveService : ISaveService
{
private readonly ILogger logger;
public FileSaveService(ILogger logger) => this.logger = logger;
private static string PathFor(string key) =>
Path.Combine(Application.persistentDataPath, key + ".txt");
public void Write(string key, string value)
{
File.WriteAllText(PathFor(key), value);
logger.Log($"File write: {PathFor(key)}");
}
public string Read(string key, string fallback = "") =>
File.Exists(PathFor(key)) ? File.ReadAllText(PathFor(key)) : fallback;
}
/// <summary>Stand-in for a cloud backend (would call a web API).</summary>
public class CloudSaveService : ISaveService
{
private readonly ILogger logger;
public CloudSaveService(ILogger logger) => this.logger = logger;
public void Write(string key, string value) =>
logger.Log($"Cloud PUT /save/{key} = {value}");
public string Read(string key, string fallback = "") => fallback;
}
}Advantages & Disadvantages
+ Advantages
- Classes depend on abstractions, so implementations can be swapped without touching consumers.
- Dependencies are explicit in the constructor or
Injectsignature — no hiddenFindcalls. - Makes Edit Mode unit tests trivial: pass in fakes and assert on them.
− Disadvantages
- MonoBehaviours cannot take constructor arguments, so you need method injection or a framework to bridge the gap.
- The composition root can grow large, and wiring errors show up at runtime rather than compile time.
- Over-abstracting everything behind interfaces adds indirection that makes simple code harder to read.
Tips
- 01Keep one composition root per scene (or one global boot installer). If many scripts are building their own services, the pattern has leaked.
- 02Use method injection (
Inject(ILogger, IAudioService, ISaveService)) for MonoBehaviours and constructor injection for plain C# classes. - 03Validate in the installer: throw early with a clear message if a required service is missing, instead of a
NullReferenceExceptionthree scenes later. - 04When the hand-written installer gets unwieldy, frameworks like VContainer or Zenject/Extenject automate binding, scoping and lifetime management.
- 05Avoid injecting the container itself into game classes — that quietly turns DI back into a Service Locator.