12 minutes
Service Locator in Unity: The Global Registry, Used Where It Belongs
Most advice about the service locator pattern comes down to one sentence: “don’t.”
That is an oversimplification, and it is worth being honest about why.
The service locator is a real tool with a real cost. It is not secretly good architecture, but it is also not a cardinal sin. The useful question is not “should I use a service locator” but “where does it belong, and what am I trading away when I use it.”
This post is a companion to the dependency injection articles already on this site. Those articles make the case for wiring dependencies at the edges. This one makes the case for the exception.
Pull versus push
Dependency injection and service location are two answers to the same question: how does an object get the collaborators it needs?
Dependency injection is a push model. Something outside the object hands it the dependencies, usually through a constructor or a setup method. The object never asks for anything. It just receives.
A service locator is a pull model. The object reaches into a shared registry and asks for what it wants, when it wants it.
// Push: the dependency arrives from outside.
public sealed class PlayerController
{
private readonly IAudioService audio;
public PlayerController(IAudioService audio)
{
this.audio = audio;
}
}
// Pull: the object goes and fetches the dependency.
public sealed class PlayerController
{
private IAudioService audio;
public void Initialize()
{
audio = Services.Get<IAudioService>();
}
}
Both end with the object holding a reference to IAudioService. The difference is who decided where that reference came from.
In the push version, the caller decides. In the pull version, the object decides, and the only thing it has to know is that a registry exists and contains audio.
The honest criticisms
The anti-service-locator arguments are correct. It helps to state them plainly instead of gesturing at them.
The dependency is hidden. Read a class that uses constructor injection and the signature tells you exactly what it needs. Read a class that pulls from a locator and you have to scan the body for every Services.Get<T>() call to build the same picture. The dependency graph still exists; it is just scattered inside method bodies.
Tests have to set up global state. A class that pulls from a static locator cannot be tested in isolation. Before the test runs, the registry has to be populated with the right fakes, and after it runs, the registry has to be cleaned up. That is global mutable state, and global mutable state makes tests order-dependent and easy to break.
Errors move to runtime. Constructor injection fails at construction time. If PlayerController needs audio and audio is missing, you find out when you try to build it. A locator can fail later, at some arbitrary point in a gameplay frame, when the object finally asks for a service that was never registered. The stack trace still points at the Get call, but the distance between the mistake and the failure has grown.
Coupling to the locator itself. Every class that calls Services.Get<T>() depends on the locator, not just on the service it wants. The locator becomes a hard-to-remove seam running through the whole codebase.
None of these are opinions. They are the mechanical consequences of hiding a dependency behind an ambient lookup. The next question is whether there are places where those costs are worth paying.
Why it is still here
If service location were purely harmful, it would have died out decades ago. It has not, because a few situations make the lookup genuinely the lesser evil.
Deep call chains. Sometimes a service has to travel through ten layers of objects before it reaches the one that uses it. Threading a logger, a profiler marker, or a random number source through every constructor in that chain adds noise to code that has nothing to do with those services. A lookup lets the leaf object grab what it needs without making every intermediate object aware of it.
Ambient services. Some things are context, not collaboration. A clock, an input source, a settings object, a frame counter. They are everywhere, they rarely change, and forcing every class to declare them makes signatures heavier without making the code clearer. The line between “ambient context” and “service locator” is thin, and it is mostly a question of discipline.
Engine boundaries. This is the Unity-specific case, and it matters most in practice.
Small games and prototypes. When the whole game is five scenes and three systems, a global registry is fast to build and easy to read. The full dependency-injection apparatus is a tax you do not need to pay yet. The risk is that the small game grows and the registry becomes the default pattern even after it stops being convenient.
The honest position is not “never use it.” It is “know what it costs, and use it where the cost is already being paid for another reason.”
Why Unity leans toward lookup
Unity makes constructor injection awkward in a specific way: the engine constructs your MonoBehaviour components, not your code.
Unity’s own documentation warns against giving MonoBehaviour a constructor, because the engine manages those objects and their lifecycle. That closes off the cleanest injection point. You cannot require an IAudioService in the constructor of a component the engine will create.
That leaves three honest options for Unity components:
- Constructor injection for plain C# classes. Keep the gameplay logic in ordinary classes that you construct yourself, and inject normally there.
- Explicit setup methods. A thin
MonoBehaviourcallsInitialize(IAudioService audio, ISaveService save)from a bootstrapper. - Service lookup at the boundary. The component pulls from a registry, accepting the hidden-dependency cost in exchange for not fighting the engine.
The third option is not automatically wrong. It is often the most honest thing to do in a codebase that is mostly Unity components. The trick is to keep the lookup at the boundary and not let it leak into the pure logic.
public sealed class PlayerHud : MonoBehaviour
{
private IAudioService audio;
private void Awake()
{
// Boundary lookup: the engine made this object, so it pulls its tools.
audio = Services.Get<IAudioService>();
}
public void OnHealthChanged(int current, int max)
{
audio.Play(current <= max / 4 ? "low-health" : "health-change");
healthBar.SetFill((float)current / max);
}
}
That component is still mostly glue. The real rules live in plain classes that can take a constructor. The lookup is confined to Awake, at the edge, where the engine already forced your hand.
A registry that fails fast
The difference between a tolerable service locator and an awful one is mostly in how it handles the unhappy path.
A locator that returns null for a missing service is the worst version. It pushes the failure into whatever calls the method, usually a null-reference exception deep in a frame, far from the actual mistake.
A locator that throws immediately on a missing registration is far better. It turns the runtime failure into a loud, early one.
public static class Services
{
private static readonly Dictionary<Type, object> registry = new();
public static void Register<T>(T service)
{
registry[typeof(T)] = service ?? throw new ArgumentNullException(nameof(service));
}
public static T Get<T>() =>
registry.TryGetValue(typeof(T), out var service)
? (T)service
: throw new InvalidOperationException(
$"No service registered for {typeof(T).Name}.");
}
Three decisions here do most of the heavy lifting:
- Registration is explicit. Nothing is registered by accident or lazily. A service either exists or it does not.
- Missing services throw. There is no
nullto forget to check. The error names the missing type. nullis rejected at the door. You cannot register anullservice and discover it later.
The remaining rule is ordering: register before you resolve. That means a bootstrap object that runs before gameplay and populates the registry once.
public sealed class GameBootstrap : MonoBehaviour
{
private void Awake()
{
Services.Register<IAudioService>(new UnityAudioService());
Services.Register<ISaveService>(new JsonSaveService());
Services.Register<IInputService>(new InputSystemService());
}
}
If the bootstrap lives in the first loaded scene and every other system is built after it, the ordering is guaranteed by the scene structure rather than by discipline.
Ambient context, the quieter sibling
The term “ambient context” describes a service locator with a narrower job: it exposes a small set of genuinely global values through a static entry point, and nothing else.
The practical difference is scope. A service locator tends to grow into a junk drawer that holds everything. An ambient context holds a handful of things that are legitimately everywhere: time, settings, a small set of core services.
public static class GameContext
{
private static readonly AmbientServices services = new();
public static TimeProvider Time => services.Time;
public static Settings Current => services.Settings;
public static void Initialize(TimeProvider time, Settings settings)
{
services.Time = time;
services.Current = settings;
}
}
This is still a locator under the hood. The benefit is restraint: the API surface is deliberately small, and it is obvious at a glance what is ambient and what is not.
The ambient context does not remove the criticisms. It just makes the surface smaller, which makes the hidden coupling easier to audit. That is a real improvement, even if it is a modest one.
Scene scope and teardown
A static registry that lives for the whole process has a second problem beyond hidden dependencies: it leaks services from unloaded scenes.
An audio mixer can live forever. A level-specific enemy spawner should die with the scene that created it. If the player unloads a dungeon and the registry still holds the dungeon’s services, the next scene inherits stale state it never asked for.
The fix is scope, not globalism. Two levels cover most cases:
- Project-wide for services that genuinely last the whole game: audio, settings, input, save.
- Scene-scoped for services that belong to one scene or level, created on load and discarded on unload.
A scene-scoped locator does not have to be a static class. It can be a plain object owned by the scene:
public sealed class SceneServices
{
private readonly Dictionary<Type, object> registry = new();
private readonly IServiceProvider project;
public SceneServices(IServiceProvider project)
{
this.project = project;
}
public void Register<T>(T service) => registry[typeof(T)] = service!;
public T Get<T>() =>
registry.TryGetValue(typeof(T), out var service)
? (T)service
: project.GetService(typeof(T)) is T fallback
? fallback
: throw new InvalidOperationException($"No service for {typeof(T).Name}");
}
The scene locator resolves its own services first and falls back to the project services for the long-lived ones. That gives you the convenience of lookup without the lifetime problem of a single process-wide bag.
This matters more than it sounds, because lifetime bugs in games are the ones that hide until a player does something unexpected: reloads a level, backs out to the menu, reconnects to a match. A registry that never forgets turns those into stale-state bugs that are hard to reproduce and harder to explain.
Where the boundary should sit
The workable rule for a Unity project is a boundary, not a ban.
Use service location:
- inside
MonoBehaviourcomponents the engine constructs, - for ambient services like time, settings, and input,
- in prototypes and small games where a full container is overhead,
- at the edge, to hand off to plain C# systems.
Use dependency injection:
- for plain C# classes that hold the actual logic,
- for stable collaborators like save, audio, and repositories,
- whenever testability of the core logic matters,
- when the object graph is large enough that central wiring earns its keep.
Use neither:
- for local state that one object naturally owns,
- for trivial values that should just be method parameters,
- for a class with no meaningful dependencies at all.
The mistake is not using one or the other. The mistake is using both in the same layer for the same kind of dependency, so nobody can tell which rule applies where.
The real cost, restated
If you take one thing away, take this: the problem was never the global itself.
A global variable is not automatically harmful. What is harmful is a dependency that is invisible, mutable, and order-dependent. A service locator makes dependencies invisible by default. That is the entire criticism, and it is enough.
Constructor injection makes dependencies visible. That is the entire defense, and it is enough too.
So the choice is not between “clean” and “dirty.” It is between two ways of trading visibility for convenience. Service location buys convenience with visibility. That is a fair price in some places and a bad one in others.
The skill is knowing which place you are in.
Common mistakes
Resolving in the middle of gameplay logic. A lookup buried inside a method is the hardest kind to audit. If the component pulls at the boundary in Awake and then treats the reference as a normal field, the damage is contained.
Returning null instead of throwing. A null return spreads the failure to unrelated code. Throw at the Get call and name the missing type.
Registering lazily, on first use. Lazy registration moves the “who sets this up” decision into whatever happens to run first. That turns initialization order into a source of bugs.
Storing request data in the locator. A current enemy position, a damage amount, a selected target: these are not services, they are request data. Putting them in a registry is how a locator becomes a global junk drawer.
Testing against the real registry. Tests that depend on a shared static registry will eventually interfere with each other. If you must test locator-bound code, reset the registry between tests and register fakes explicitly.
Practical takeaway
Treat the service locator as a boundary tool, not a foundation.
Use it where the engine or the environment has already forced a lookup: inside MonoBehaviour components, for ambient services, in prototypes and small games. Make it fail fast, register up front, and keep the lookup out of the core logic.
Use constructor injection for the plain C# classes that do the real work. Keep their dependencies visible, so the code that matters most is also the code that is easiest to read and test.
The pattern is not a sin. It is a trade. The only real mistake is making that trade without knowing you made it.
References
- Robert Nystrom, Service Locator
- Mark Seemann, Service Locator is an Anti-Pattern
- Martin Fowler, Inversion of Control Containers and the Dependency Injection pattern
- Unity Manual, MonoBehaviour
- Coyote Time, Dependency Injection in Unity
- Coyote Time, Build a Simple DI Container in C#