Back to Journal

Service Locator and Dependency Injection in Game Code

Whether an object finds what it needs or is handed it decides how readable, testable and replaceable game code becomes, and Unity's ownership of MonoBehaviour creation shapes how that choice can be made.

Every non trivial piece of game code depends on something else. An enemy needs to know where the player is; a shop needs the player's wallet; a footstep needs the audio system and the surface the foot has struck. The question of how those dependencies arrive, who creates them and how a class gets hold of them, sounds like a matter of taste, and in a small prototype it nearly is. In a project that lives for years, it becomes one of the quiet forces that decides whether the code can still be changed.

There are, broadly, two answers. In the first, a class goes looking for what it needs, asking some central registry for an audio service or a save service when the moment arrives. In the second, a class declares what it needs, and something outside it supplies those things before the class is asked to do any work. The first is the service locator; the second is dependency injection. The difference is one of direction, and direction, in architecture, turns out to matter enormously.

Unity complicates both, because the engine, not the programmer, creates every MonoBehaviour, and it does so through scene loading and Instantiate rather than through constructors a programmer can call with arguments. Any honest account of these patterns in game code must therefore explain not only what they are, but how they bend to fit an engine that insists on owning the birth of its objects.

Finding versus being given

Consider a component that plays a sound when a gate opens. One version reaches out in its Start method, perhaps through a singleton or a FindFirstObjectByType call, and grabs an AudioService. Another version exposes a serialized field of type AudioService, and the designer drags the right object into it in the Inspector. A third has a method, Initialize, that receives the AudioService as a parameter. All three play the same sound. They differ in who knows about the connection, and when.

In the first version, the knowledge is buried inside the component. A reader must open the code to discover that the gate needs audio at all, and a test must arrange for the right object to exist in the right place so that the search succeeds. In the second and third, the dependency is visible from outside: anyone looking at the Inspector, or at the call to Initialize, can see what the gate requires and substitute something else.

This is the core of the idea sometimes called inversion of control. Instead of each object controlling the acquisition of its collaborators, control is moved outward, to whatever creates and arranges the objects. The gate stops caring where its audio comes from. It only declares that it needs something able to play a sound, ideally expressed as an interface such as IAudioService, and trusts that the world will provide one before the first gate swings open.

The service locator and its bargain

The service locator is the most common middle ground between a raw singleton and full injection. It is a single registry, often a static class, with methods such as Register and Get. At startup, the game registers concrete services under interface types: this object is the IAudioService, that one is the ISaveService. Later, any code can call ServiceLocator.Get with the interface it wants, and receive whichever implementation was registered.

The bargain has real advantages over the singleton. Code depends on an interface rather than on a concrete class, so a silent audio service can be registered during tests, or a logging decorator wrapped around the real one while hunting a bug. The registry can be cleared and repopulated when the game returns to the main menu. Nystrom's Game Programming Patterns, which devotes a chapter to the pattern, describes it with genuine sympathy, and for many projects it is enough.

The cost is that the locator remains global, and its dependencies remain hidden. A class that calls ServiceLocator.Get inside some deep method still conceals what it needs; the only difference from a singleton is that the thing it conceals can now be swapped. Errors also move from compile time to runtime: if nobody registered the ISaveService, the code compiles happily and fails only when a player reaches the first save point, perhaps hours into a session.

Injection and the problem of the constructor

Dependency injection removes the lookup entirely. In ordinary C#, the purest form is constructor injection: a class lists its dependencies as constructor parameters, stores them in readonly fields, and cannot exist at all without them. The compiler enforces completeness, and the class is honest by construction. Plain C# classes in a Unity project, such as a pathfinding service, an economy model or a save serializer, can use constructor injection freely, and often should.

MonoBehaviour cannot. Unity creates components itself, when a scene loads or when Instantiate copies a prefab, and it calls no constructor that a programmer can supply arguments to. Writing a constructor on a MonoBehaviour is discouraged and does not give a place to receive dependencies. So for components, injection takes other forms: method injection, through an initialization method called after creation, or field injection, in which a framework or a serialized reference fills in the field before the component's logic runs.

Method injection is the simplest to write by hand. The component declares a public method, often named Construct or Initialize, that takes its dependencies as parameters and stores them. Whoever creates the component, a spawner, a bootstrap script, a factory, calls that method immediately after Instantiate. The component can check in its Start method that it was initialized, and fail loudly if not, which recovers some of the safety constructors provide.

Field injection is what Unity's Inspector already does, for references that exist at edit time. A serialized field holding a ScriptableObject asset or a scene object is injection performed by the designer and the serializer together. Frameworks extend the same idea to runtime: a field marked with an Inject attribute is filled by the container when the object is created. Field injection is convenient, though it hides dependencies a little more than method injection, since the fields can be private and scattered.

The composition root

If objects no longer find their dependencies, something must create and connect them. That place is the composition root: ideally one location, near the start of the program, where concrete services are constructed and handed to the objects that need them. In a Unity game it is typically a bootstrap scene containing a single component, whose Awake method builds the economy, the save system and the audio wrapper, then passes them into the managers and spawners that follow.

Concentrating construction in one place has a quiet, cumulative benefit. The entire dependency graph of the game becomes readable in a single file, like the ledger of a household in which every expense is recorded on one page. Swapping an implementation, for a platform port, a test, or a debug build with cheats enabled, means editing that file and nothing else. The rest of the code neither knows nor cares which concrete objects it was given.

Objects created later, such as enemies spawned at nightfall or villagers born in a settlement, need dependencies too. The usual answer is a factory: an object built in the composition root, holding the services, whose job is to instantiate prefabs and inject them. When the dead come for a palisade in a game like Crown & Ashes, each spawned attacker could receive its pathfinding and target services from such a factory, without any attacker ever reaching into a global to find them.

Frameworks that do the wiring

Writing the composition root by hand is entirely viable for small and medium projects, and it has the virtue of being plain code that anyone can read. As the number of services grows, though, the wiring becomes repetitive, and libraries known as DI containers automate it. A container is told which concrete type satisfies each interface and with what lifetime, and it then builds objects on request, supplying their dependencies recursively.

In the Unity world, Zenject has long been the best known framework, and Extenject is its community maintained continuation. It introduces installers, classes that declare bindings, and contexts at project, scene and game object level, so that services can be shared across the whole game or scoped to a single scene. Components receive dependencies through fields, properties or methods marked with its Inject attribute, and prefabs instantiated through its factories are injected automatically.

VContainer is a newer alternative that emphasizes speed and a smaller surface. Its central idea is the LifetimeScope, a component that acts as a composition root for a scene or part of a hierarchy, with registrations written in code. Both libraries are capable, and both demand that a team learn their conventions; neither is required to apply the underlying principle. A container is a convenience on top of injection, not a precondition for it.

Choosing with open eyes

A pragmatic project often mixes approaches. Edit time references flow through the Inspector, which is already a form of injection. Plain C# services use constructor injection inside a composition root. A small number of truly global, low stakes services, such as logging, may sit behind a locator. What matters is that the mixture is deliberate, and that the core gameplay logic, where most of the bugs and most of the changes will live, depends on things it was given rather than things it found.

The real test of any arrangement is how it behaves on a bad day: when a scene fails to load, when a test must be written for a bug that only appears in the fourth hour of play, when a service must be replaced for a console port. Code that declares its dependencies can be reasoned about on such days, piece by piece. Code that reaches into globals can only be run and watched, and watching, as any developer who has stared at a log at three in the morning knows, is a poor substitute for understanding.

None of these patterns is a moral position, whatever the arguments on forums might suggest. They are tools for controlling the direction in which knowledge flows through a program. Choosing between them is a matter of asking, for each dependency, who ought to know about it, and then arranging the code so that exactly those parties, and no others, do.