There is a file in my Unity project called ServiceLocator.cs. It is eighty-three lines long, it wraps a static Dictionary<Type, object>, and by the standard reading of the literature it is an anti-pattern.
Fifty-four files use it. There is no DI container in the project — not Zenject, not Extenject, not VContainer. The package manifest has no entry for one, and that was a decision rather than an omission.
I want to lay out how I got there, because the interesting part is not the conclusion. Plenty of projects should use a container and I have shipped some of them. The interesting part is that this project got a derivation instead of a default.
The whole thing
public static class ServiceLocator
{
static readonly Dictionary<Type, object> registry = new();
public static void Register<T>(T service) where T : class, IService
{
var type = typeof(T);
if (!registry.TryAdd(type, service))
{
Debug.LogWarning($"[ServiceLocator] {type.Name} already registered — overwriting.");
registry[type] = service;
}
}
public static T Get<T>() where T : class, IService
{
if (registry.TryGetValue(typeof(T), out var service))
return (T)service;
throw new InvalidOperationException(
$"[ServiceLocator] {typeof(T).Name} not registered. Is the Bootstrapper in the scene?");
}
public static bool TryGet<T>(out T service) where T : class, IService { … }
public static void Replace<T>(T service) where T : class, IService { … }
public static void Clear() => registry.Clear();
}
IService is a marker interface with no members. Its only job is the generic constraint: you cannot put an arbitrary object into this registry. A type becomes a service by declaring that it is one, in its own signature, where you can grep for it.
There is one escape hatch — RegisterComponent<T>() where T : Component — for Unity built-ins like Camera that I do not control and cannot make implement my interface. It is the only place the marker is bypassed, and it exists because pretending otherwise would have meant a wrapper class whose entire purpose was to satisfy a rule I invented.
The composition root is a MonoBehaviour with a long inspector
Registration happens in exactly one place:
[DefaultExecutionOrder(-100)]
public class Bootstrapper : MonoBehaviour
{
[SerializeField] ObjectManager objectManager;
[SerializeField] CameraController cameraController;
[SerializeField] TableSurface tableSurface;
// … about twenty of these
void Awake()
{
ServiceLocator.Clear();
if (cameraController != null)
{
ServiceLocator.Register(cameraController);
ServiceLocator.Register<ICameraControl>(cameraController);
}
if (tableSurface != null)
{
ServiceLocator.Register(tableSurface);
ServiceLocator.Register<IRaycastSurface>(tableSurface);
ServiceLocator.Register(new DeskWorldFrame(tableSurface));
}
// …
}
}
[DefaultExecutionOrder(-100)] guarantees this Awake runs before any consumer's. Clear() at the top makes the registry deterministic across domain reloads in the editor, and there is a matching Clear() in OnDestroy.
It is a null-check ladder. It is not elegant. It is also the single file you open when you want to know what exists in this application and who owns it, and it fits on two screens.
What the usage numbers actually say
I counted the call sites across the project:
| Call | Count |
|---|---|
ServiceLocator.TryGet | 112 |
ServiceLocator.Register | 42 |
ServiceLocator.Get | 10 |
That ratio — eleven TryGet for every Get — is the most informative thing in this article, and I did not design it. It emerged.
It means that in this codebase, the overwhelming majority of dependencies are optional. Not "optional" in the lazy sense of nobody having thought about it. Optional in the domain sense.
SceneBridge resolves five collaborators in Start:
ServiceLocator.TryGet(out fakeWrapper);
ServiceLocator.TryGet(out ghostRenderer);
ServiceLocator.TryGet(out raycastSurface);
ServiceLocator.TryGet(out cameraControl);
ServiceLocator.TryGet(out groupResolver);
Every one of those can legitimately be absent. FakeWrapper is an in-editor substitute that does not exist in a WebGL build at all. GhostRenderer is only present when the scene has ghost previews configured. The lab runs in several modes — a full lab, a model testing stand, a spatial editor — and each populates a different subset of the same registry.
A container's core promise is that it resolves your whole object graph and fails loudly when it cannot. That promise is worth a great deal when your graph is mandatory. When two-thirds of your edges are conditional on runtime mode, you spend your time teaching the container which absences are fine — optional bindings, conditional installers, per-mode subcontainers — and the configuration describing the exceptions grows toward the size of the thing it configures.
I am not claiming a container cannot express this. It can. I am claiming that in this project the expression costs more than the problem.
I did not reject interfaces. I rejected the container.
This distinction gets collapsed constantly, so it is worth being explicit.
Three abstractions are registered alongside their concrete types:
public interface ICameraControl : IService { … }
public interface IRaycastSurface : IService { … }
public interface IGroupResolver : IService { … }
SceneBridge holds ICameraControl, not CameraController. That is a real seam, and it earns its keep: a second composition root for the spatial editor mode registers a different camera implementation and calls ServiceLocator.Replace to swap it at runtime.
Everywhere else, consumers depend on concrete types, because there is one implementation, there has never been a second one, and inventing IObjectManager to have an interface would produce a file that exists to satisfy a principle rather than a need.
Dependency inversion is the principle. A container is one implementation of it. The two are not the same thing, and the codebases I have enjoyed least were the ones that had the container and not the inversion — a hundred interfaces with exactly one implementor each, bound in an installer, injected through a constructor, and coupled just as tightly as before.
The testability argument, and the shape that dissolves it
The strongest case for constructor injection is testing: you cannot substitute a collaborator you did not receive as a parameter. A static locator is genuinely hostile to that. This is the real objection and it deserves a real answer.
Mine is that the code I unit-test has no collaborators to substitute.
Here is the entire EditMode test suite:
BufferGrowthTests
ObjectSlotRegistryTests
OverrideBufferReaderTests
SceneBufferWriterTests
TransitionStateTests
TypeParameterMapTests
Not one of them touches ServiceLocator, because not one of the classes under test knows it exists. They are pure C#. SceneBufferWriter takes a float[] and a stride and writes floats into it. ObjectSlotRegistry maps string ids to integer indices. TransitionState is arithmetic over time.
The architecture is a thin shell and a pure core. MonoBehaviours resolve services, marshal data, and delegate. The logic worth testing lives in Unity-free classes that receive their data as arguments and hold no references to anything.
In that shape, constructor injection buys nothing, because the code you would inject into is not the code you test — and the code you test needs no injection, since it has no dependencies by construction. The DI-for-testability argument is completely sound and simply does not reach this codebase.
I do not offer that as a universal claim. In a project where the business logic genuinely lives in service classes that call other service classes, the argument lands squarely and a container is the right answer.
What it actually costs me
A piece like this is worthless if it only lists wins. Here is the bill.
Dependencies are invisible from the outside. This is the fundamental cost and there is no dodging it. You cannot read a class's signature and learn what it needs. You have to read its Start method, and possibly the rest of it, because nothing stops a TryGet appearing halfway down a file. Constructor injection makes that information a type signature. My approach makes it a convention.
Registration order is a convention, not a guarantee. [DefaultExecutionOrder(-100)] and one null-check ladder. It works. It is enforced by an attribute and my attention, not by the compiler.
Double registration is a warning, not an error. Register logs and overwrites. In a bigger team that is a bug waiting for a quiet afternoon.
Static state needs discipline. The Clear() calls in Awake and OnDestroy exist because a static registry survives things you would rather it did not.
And a real one from my own journal. Some classes I would like to unit-test are unreachable from the test assembly, because most of the project compiles into Assembly-CSharp and only three assemblies have .asmdef files. A colour-resolver I wrote deliberately Unity-free — precisely so it would be testable — still has no unit test, because the test assembly cannot reference the assembly it landed in.
That is not the locator's fault. But it is the failure mode of my approach: the pure-core strategy depends on packaging discipline, and I have slipped on it. A container would not have fixed that either, which is exactly why I am reporting it as a cost of my architecture rather than a point in its favour.
The column of the ledger that is specific to this build
This ships as a Unity WebGL build. That is not a footnote; it changes several numbers.
The build is IL2CPP ahead-of-time compiled, with no JIT and aggressive managed-code stripping. Reflection-driven resolution is the thing that historically needs the most care in that environment. Modern containers handle it — Zenject ships reflection baking specifically for IL2CPP, and VContainer leans on source generation — and the fact that both invested in exactly that problem tells you the friction is real, not imaginary.
Everything ships to a browser. Every binding, installer, factory and container type is code a student downloads before the lab opens. I already bundle and minify the JavaScript side for cold-start latency; adding megabytes of resolution machinery to save myself a null check would be an odd trade to make with a straight face.
And there is one scene. One lab, loaded once, running until the tab closes. Scoped lifetimes — per-scene containers, per-object contexts, subcontainer hierarchies — are among the most valuable things a container gives you, and I have zero use for them. The nearest thing I have to a scope is a second bootstrap for the editor mode that calls Replace once.
The tool that stops being a decision
Here is the part I actually care about, and it is not about any particular library.
Zenject is good software. It solves genuinely hard problems — object graph composition, lifetime scoping, factories for runtime-instantiated objects, signals — and it solves them thoroughly enough that large Unity projects lean on it for years. Nothing in this article is a criticism of it.
What I have watched happen, more than once, is a tool moving from something you choose to something you bring. It arrives in the manifest of the next project on day one, before anyone has written down what that project's object graph looks like, how many scenes it has, what its startup budget is, or whether anything in it needs a scope. It is there because it was there last time.
That is the moment a technique stops being a principle and becomes a habit wearing a principle's clothes. The tell is easy to spot once you know it: the answer to a design problem starts being a more advanced feature of the tool. You need a subcontainer. You need a custom factory. You need to reorder the installers. Nobody asks whether the indirection causing the problem should exist at all, because the framework is a fixed point in the discussion and only your code is negotiable.
Every project deserves its own derivation. Sometimes that derivation takes ten minutes and lands on the same answer as last time — and then you have a reason, which is worth much more than the same answer without one. This one took an afternoon and landed somewhere textbooks call a mistake.
What would make me switch tomorrow
I want this to be falsifiable, so here are the specific conditions. Any two of these and I would be reading container docs the same week:
- A second and third developer on the Unity side. The invisible-dependency cost scales with the number of people who did not write the code.
- Multiple scenes with genuinely different lifetimes, where objects must be created and destroyed per scope rather than living for the session.
- Runtime-instantiated objects that need dependencies injected. Right now everything with dependencies is a scene object wired in the inspector. The moment I am hand-rolling factories that pass three services into every new instance, I have started writing a container badly.
- The service count crossing roughly forty. Twenty fits in one file and one head. Forty does not, and a null-check ladder that long stops being readable and starts being a place bugs hide.
- Mandatory dependency graphs. If
TryGetandGetever invert — if most dependencies become required — then the container's fail-loudly promise starts paying for itself and my optional-by-default design is working against the grain.
None of those are true today. Several will be true eventually, and when they are I will not feel clever about the eighty lines. I will delete them.
The actual argument
The service locator is not the point of this article. It is a small file that suits a specific set of constraints: one scene, about twenty services, mostly optional edges, a pure-core test strategy, a download-size budget, and one developer on this side of the codebase. Change any three of those and it becomes the wrong answer, and I have tried to be precise about which three.
The point is that it was derived. Somebody sat down with the constraints of this project and worked out what fit, rather than reaching for what fit the last one.
That is a habit worth more than any particular verdict it produces — including this one.