๐ Lesson 1.2: Dependency Injection & Inversion of Control
Assemblies (Lesson 1.1) enforce which modules may depend on which. But inside those boundaries, how does a gameplay class get hold of the audio system, the save system, the analytics service it needs? The instinctive answers โ a singleton, FindObjectOfType, a public field dragged in the Inspector โ all weld classes to concrete types and to each other. This lesson replaces that with Inversion of Control: classes declare what they need as interfaces, and something external hands them a concrete implementation. You'll build a tiny DI container by hand so the idea is never magic, then weigh it against the Service Locator and the mature frameworks.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Diagnose the coupling caused by singletons and
FindObjectOfType, and explain why it hurts testing and change - Define Inversion of Control and distinguish the three injection styles โ constructor, method, property
- Use interfaces as seams so consumers depend on abstractions, not concrete classes
- Write a small hand-rolled DI container that registers and resolves services
- Compare Service Locator and true DI, and place both correctly in a composition root
- Recognize when to reach for VContainer or Zenject instead of rolling your own
Estimated Time: 60 minutes ยท Prerequisite: Lesson 1.1 (assemblies) and comfort with C# interfaces & generics
In This Lesson
The Tight-Coupling Problem
Consider a wholly ordinary Unity pattern. A player script wants to play a footstep sound, so it grabs the audio manager the easy way:
public class PlayerController : MonoBehaviour
{
void Step()
{
// The tempting, tightly-coupled way.
AudioManager.Instance.Play("footstep");
}
}
It works โ and it quietly creates four problems that compound as the project grows.
- Hidden dependency. Nothing in
PlayerController's signature says it needs audio. You only discover it by reading every line. Its true dependencies are invisible. - Hard-wired concrete type. It's bolted to the exact class
AudioManager. Want a different implementation for mobile, or a silent one, or a fake? You can't โ the type is baked in. - Untestable. To unit-test
Step()you must spin up a realAudioManagersingleton and an audio system. There's no seam to insert a stand-in. - Global mutable state.
Instanceis a global anyone can reach and mutate, with lifetime and initialization-order hazards that surface as null-reference bugs on scene load.
FindObjectOfType<AudioManager>() and public-field-dragged-in-the-Inspector are the same disease in different clothes: the consumer reaches out and takes a concrete dependency. The cure is to invert that.
๐ Definition
Coupling is how tightly one class is bound to the details of another. When PlayerController names AudioManager directly, they're tightly coupled โ you can't change or test one without the other. The goal is loose coupling: the consumer depends only on an abstraction, and the concrete choice is made elsewhere.
Inversion of Control
Inversion of Control (IoC) is a simple flip in who decides. Normally a class controls its own dependencies: it constructs or looks up the concrete objects it uses. Under IoC, that control is inverted โ the class stops reaching out for its dependencies and instead receives them from the outside. It says what it needs; something else decides what to give it.
Dependency Injection (DI) is the most common way to achieve IoC: dependencies are injected (passed in) rather than looked up. "Don't call us, we'll call you" โ the class is handed everything it requires and is free to just use it.
Rewriting the player with injection:
public class PlayerController : MonoBehaviour
{
private IAudioService _audio; // depends on an abstraction
// Something external injects the concrete service.
public void Construct(IAudioService audio) => _audio = audio;
void Step() => _audio.Play("footstep");
}
Now the dependency is explicit (it's in the API), abstract (an interface, not a class), and swappable (a test can pass a fake). The player no longer knows or cares which audio implementation it got. That is the whole game.
๐ก IoC vs DI. IoC is the principle (control over dependencies moves outward); DI is a technique that implements it (dependencies are passed in). The Service Locator later in this lesson is also a form of IoC โ a different, more compromised one.
Interfaces as Seams
The linchpin is the interface. It's the seam along which you separate what a service does from how it does it. Define the contract:
public interface IAudioService
{
void Play(string clipId);
void SetVolume(float volume);
}
Then supply one or more implementations behind it โ the real one, plus whatever alternatives you need:
public class AudioService : IAudioService
{
public void Play(string clipId) { /* real AudioSource work */ }
public void SetVolume(float volume) { /* ... */ }
}
// A test double: records calls, makes no sound.
public class FakeAudioService : IAudioService
{
public string LastClip;
public void Play(string clipId) => LastClip = clipId;
public void SetVolume(float volume) { }
}
Because PlayerController depends on IAudioService, you can hand it AudioService in the game and FakeAudioService in a test, and it can't tell the difference. The interface is the crack you pry the concrete type out through โ the same seam idea that breaks the assembly cycles from Lesson 1.1: put the interface in Core, let both sides depend down on it.
Now visualize the payoff. Below, the same four classes are drawn two ways โ tangled by direct references, then untangled through interfaces.
Three Ways to Inject
There are three places a dependency can be handed in. Each has a moment it fits.
๐๏ธ Constructor
Dependencies are parameters of the constructor. The object is never valid without them โ the compiler enforces it, and the dependency is immutable after. The default choice for plain C# classes.
๐ฅ Method
Passed into a specific method (or an Init/Construct method). Useful for MonoBehaviours, which Unity constructs for you so you can't use a real constructor.
๐ง Property
Set through a public property/setter after construction. The most optional and the least safe โ the object can exist half-wired โ so reserve it for genuinely optional dependencies.
For pure C# classes, prefer constructor injection โ the object literally cannot be built in an invalid state:
public class ScoreReporter
{
private readonly IAnalytics _analytics;
// Can't construct a ScoreReporter without analytics โ dependency is guaranteed.
public ScoreReporter(IAnalytics analytics) => _analytics = analytics;
public void Report(int score) => _analytics.Track("score", score);
}
โ ๏ธ MonoBehaviours can't use constructors
Unity instantiates MonoBehaviours itself (via AddComponent / scene load), so you never call their constructor and can't inject through it. For components, use method injection โ a Construct(...) or Inject(...) method your composition root calls right after instantiation โ which is exactly what real DI frameworks do for you under the hood.
A Hand-Rolled DI Container
Unity ships no DI container. To make the idea concrete โ and to demystify the frameworks โ let's build a minimal one. A container does two jobs: you register which implementation stands behind each interface, then you resolve an interface to get that implementation.
using System;
using System.Collections.Generic;
public class ServiceContainer
{
// Map: interface type -> factory that produces its implementation.
private readonly Dictionary<Type, Func<object>> _factories = new();
private readonly Dictionary<Type, object> _singletons = new();
// Register a single shared instance (created once, reused).
public void RegisterSingleton<TInterface>(TInterface instance)
=> _singletons[typeof(TInterface)] = instance;
// Register a factory: a new instance is produced on each Resolve.
public void Register<TInterface>(Func<TInterface> factory)
=> _factories[typeof(TInterface)] = () => factory();
// Resolve an interface to its registered implementation.
public TInterface Resolve<TInterface>()
{
var type = typeof(TInterface);
if (_singletons.TryGetValue(type, out var single))
return (TInterface)single;
if (_factories.TryGetValue(type, out var factory))
return (TInterface)factory();
throw new InvalidOperationException($"No registration for {type.Name}");
}
}
Usage reads exactly like the frameworks: register everything once, then resolve as needed.
var container = new ServiceContainer();
// Wire concrete implementations behind their interfaces โ once, up front.
container.RegisterSingleton<IAudioService>(new AudioService());
container.Register<IScoreReporter>(() =>
new ScoreReporter(container.Resolve<IAnalytics>()));
// Later, a consumer asks for the abstraction and gets a concrete.
IAudioService audio = container.Resolve<IAudioService>();
audio.Play("footstep");
That's the whole essence of a DI container in ~30 lines: a dictionary from interface to factory, plus resolve. Production frameworks add automatic constructor injection (they inspect a type's constructor and resolve its parameters for you), lifetime scopes, and MonoBehaviour injection โ but the core is this.
๐ก Two lifetimes, already. Note the split above:RegisterSingletonhands back one shared instance forever;Registerruns the factory every resolve for a fresh one. "Singleton vs transient" lifetime is a decision every container makes โ here it's just which dictionary you used.
Service Locator vs DI
There's a close cousin worth knowing precisely because it's so tempting โ and often a trap. A Service Locator is a global registry consumers pull from:
// Consumers reach into a global to FETCH dependencies.
public static class Services
{
private static readonly ServiceContainer _c = new();
public static void Provide<T>(T impl) => _c.RegisterSingleton(impl);
public static T Get<T>() => _c.Resolve<T>();
}
// Somewhere deep in a class:
var audio = Services.Get<IAudioService>(); // pulled, not injected
It fixes the worst part of the singleton โ at least you code against IAudioService, so implementations are swappable and fakeable. But it keeps the part DI removes: the dependency is still hidden. A class using Services.Get looks self-sufficient while secretly reaching into a global. You can't tell what it needs from its signature, and a test must populate the global locator before the class runs.
โ True DI
Dependencies are pushed in and visible in the constructor/method signature. Honest, testable, but requires wiring at a composition root.
โ๏ธ Service Locator
Dependencies are pulled from a global. Less wiring, easy to retrofit, but hides what a class depends on โ closer to a well-behaved singleton.
The pragmatic verdict: prefer real injection for your own gameplay code, where honest signatures pay off. A Service Locator is a reasonable pragmatic compromise for cross-cutting concerns (logging, analytics) or when retrofitting DI into a codebase that wasn't built for it โ but treat it as a step down, not the goal. We'll actually use a small container-as-locator hybrid in the Module 1 mini-project (Lesson 1.5), eyes open about the trade-off.
The Composition Root
If consumers no longer create their own dependencies, someone must โ in exactly one place. That place is the composition root: the single location, as close to the program's entry point as possible, where the entire object graph is assembled. It's the one spot allowed to name concrete types (it's the only box on the right of Figure 1 that touches concretes).
// The composition root โ the ONE place that knows concrete types.
public class GameBootstrapper : MonoBehaviour
{
void Awake()
{
var container = new ServiceContainer();
// Register every service behind its interface.
container.RegisterSingleton<IAudioService>(new AudioService());
container.RegisterSingleton<ISaveService>(new FileSaveService());
// Inject into scene objects (method injection for MonoBehaviours).
var player = FindFirstObjectByType<PlayerController>();
player.Construct(container.Resolve<IAudioService>());
}
}
Everything downstream of the bootstrapper is blissfully ignorant of concrete types. Change AudioService to MobileAudioService? One line, one place. This GameBootstrapper is the seed of the Module 1 mini-project, where we grow it into a full boot sequence that initializes services asynchronously and then loads the game.
๐ The ecosystem: VContainer & Zenject
You don't have to hand-roll this forever. Two mature DI frameworks dominate Unity: VContainer (modern, allocation-light, fast โ the common recommendation today) and Zenject/Extenject (older, feature-rich, heavier). Both give you automatic constructor injection, MonoBehaviour injection, lifetime scopes tied to scenes, and a proper composition root API. Learn the concepts with your own container first โ as we just did โ then adopt a framework, and its magic will look like exactly what you already built.
Hands-on Challenge
๐๏ธ Exercise 1: De-singleton a class
Objective: Convert a tightly-coupled class to injection and prove it's testable.
- Start with a class that calls
AudioManager.Instance.Play(...)directly. - Extract an
IAudioServiceinterface with aPlay(string)method. - Change the class to receive an
IAudioService(constructor if it's plain C#, aConstructmethod if it's aMonoBehaviour). - Write a
FakeAudioServicethat records the last clip played. - In a quick test (or a throwaway scene), inject the fake, call the method, and assert the fake recorded the right clip.
โ The key move
Once the class depends on IAudioService instead of AudioManager.Instance, the test injects FakeAudioService and checks fake.LastClip == "footstep" with no audio system, no singleton, no scene setup. That's the entire point: the seam made it testable. Contrast with the original, which you couldn't test without a live AudioManager.
๐๏ธ Exercise 2: Extend the container
Take the ServiceContainer from this lesson and add a bool IsRegistered<T>() method, plus a Resolve that throws a clear error naming the missing type (it already does โ verify it). Then register two services where one depends on the other (e.g. IScoreReporter needs IAnalytics) and resolve the dependent one. Confirm the factory pulls its own dependency from the container.
โ What to notice
The factory for IScoreReporter calls container.Resolve<IAnalytics>() itself, so resolving the reporter transitively resolves analytics. This manual chaining is precisely what a real framework automates by reflecting over constructors โ you're seeing the machinery a framework hides.
๐ฏ Quick Quiz
Question 1: What does Inversion of Control invert?
Question 2: Why is constructor injection preferred for plain C# classes?
Question 3: How does a Service Locator differ from true dependency injection?
Question 4: What is a composition root?
Summary
๐ Key Takeaways
- Singletons and
FindObjectOfTypecreate tight coupling: hidden, concrete, untestable dependencies backed by global state. - Inversion of Control flips who supplies dependencies โ the class receives them; DI injects them rather than looking them up.
- Interfaces are the seam that lets a consumer depend on an abstraction while the concrete choice is made elsewhere (and swapped for a fake in tests).
- Inject via constructor (preferred, guarantees validity), method (for
MonoBehaviours), or property (optional deps). - A DI container is just a map from interface to factory plus resolve โ you built one in ~30 lines; frameworks add auto-injection and lifetimes on top.
- A Service Locator hides dependencies (a step down from DI); the composition root is the one place that names concrete types. VContainer/Zenject are the ecosystem's frameworks.
๐ What's Next?
You can now structure code and wire it cleanly. Next we take control of time. In Lesson 1.3: Async & Await in Unity 6 โ Awaitable, you'll move past coroutines to real async/await with Unity 6's new Awaitable type โ the tool your bootstrapper will use to initialize those injected services in order before the game starts.
๐ Loosely coupled, by design
Depend on interfaces, inject the concretes at one composition root, and your code becomes swappable and testable. This is the wiring pattern the whole module's mini-project is built on.