Dependency Injection and Service Locator Patterns

Introduction #

Most non-trivial gameplay code needs to talk to other systems: an ability needs the audio system, a UI screen needs the save system, almost everything needs input. Dependency injection and the service locator pattern are the two common answers to “how does this code get a reference to that system,” without every class reaching for a global singleton by hand.

Best Practices #

  • Prefer injection for anything you’ll want to test or swap. A constructor or field that receives its dependency is trivial to substitute with a mock or a stub. A hardcoded call to a locator is not.
  • Prefer a locator for genuinely engine-wide systems. Something like input or audio that legitimately has one instance for the whole game session, reached from dozens of unrelated places, is exactly what a well-scoped locator (or subsystem manager) is for.
  • Don’t let either pattern hide a real circular dependency. If two systems need each other to construct, that’s a design smell no amount of injection or locating actually fixes.

Dependency injection #

With dependency injection, a class declares what it needs, and something else (a constructor caller, or a DI framework) supplies it. The class never asks a global for its dependency, it just receives it.

public interface IAudioService
{
    void PlaySound(string id);
}

public class AbilityCast
{
    private readonly IAudioService audio;

    // the dependency is supplied here, not fetched from a global
    public AbilityCast(IAudioService audio)
    {
        this.audio = audio;
    }

    public void Cast()
    {
        audio.PlaySound("cast_fx");
    }
}

// the caller decides which implementation to supply
var cast = new AbilityCast(new DefaultAudioService());

This makes AbilityCast trivially testable (hand it a fake IAudioService in a unit test) and makes swapping audio backends a one-line change at the call site, not a search-and-replace through every class that plays a sound.

Service locator #

A service locator instead exposes a central registry that any code can query for the system it needs, at the point it needs it, rather than receiving it up front.

public class AbilityCast
{
    public void Cast()
    {
        // fetched on demand from a central registry, not supplied by a caller
        ServiceLocator.Get<IAudioService>().PlaySound("cast_fx");
    }
}

This is exactly the shape of Heathen’s own Game Framework, modeled directly on Unreal Engine’s own Subsystem family (EngineSubsystem, GameInstanceSubsystem, WorldSubsystem, LocalPlayerSubsystem). A SubsystemManager owns the registered subsystems for a given scope, and any code with access to that manager can fetch the one it needs by type, without every caller needing a constructor-injected reference threaded all the way down to it.

Which one, and when #

These aren’t rivals, most real codebases use both. Small, focused, easily-testable classes lean on injection. Broad, session-wide systems that dozens of unrelated call sites need to reach lean on a locator or subsystem manager, exactly the role Game Framework’s Subsystems fill. The mistake is using a locator everywhere out of convenience, which quietly turns every class into something that can only be tested against the real global state, not the pattern itself.

Rate This Article!