Every Unity project hits the same question: how do scripts find each other? The two most common answers are singletons and service locators. Both solve the “I need a reference to that thing” problem, but they do it differently and the tradeoffs matter more than most people think.
A singleton says “there is exactly one of me, and everyone can reach me through a static property.” A service locator says “there is a central registry, and you ask it for what you need.” The singleton is the thing. The service locator knows where the thing is.
This means singletons couple your code to a specific instance, while service locators couple your code to a registry. Neither eliminates coupling entirely, but the kind of coupling changes what you can do later. For example:
- Singletons make prototyping fast: slap
AudioManager.Instance.Play("boom")anywhere and it works - Service locators make swapping implementations easy: register a
MockAudioServiceduring tests - Singletons survive scene loads (with
DontDestroyOnLoad), service locators let you scope per scene - Singletons fight you when you need two of something, service locators don’t care
The Classic Singleton
The version you’ll find in every Unity tutorial:
public class AudioManager : MonoBehaviour
{
public static AudioManager Instance { get; private set; }
void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
}
public void PlaySound(string clipName)
{
// play the sound
}
}Any script anywhere calls AudioManager.Instance.PlaySound("hit"). No setup, no wiring, no fuss.
The problems creep in later. Every class that calls AudioManager.Instance is permanently welded to that concrete class. You can’t test without a real AudioManager in the scene. You can’t swap it for a different implementation. Initialization order is a coin flip across scenes. And if you ever need two audio managers (split audio buses, per-scene ambient audio), you’re rewriting everything.
The Service Locator
A service locator is a step toward decoupling. Scripts register themselves, other scripts look them up by type or interface:
public static class ServiceLocator
{
private static readonly Dictionary<Type, object> _services = new();
public static void Register<T>(T service) => _services[typeof(T)] = service;
public static void Unregister<T>() => _services.Remove(typeof(T));
public static T Get<T>()
{
if (_services.TryGetValue(typeof(T), out var service))
return (T)service;
throw new Exception($"Service {typeof(T)} not registered.");
}
public static void Clear() => _services.Clear();
}Registration happens in a bootstrapper or the service itself:
public class AudioService : MonoBehaviour, IAudioService
{
void Awake() => ServiceLocator.Register<IAudioService>(this);
void OnDestroy() => ServiceLocator.Unregister<IAudioService>();
public void PlaySound(string clipName)
{
// play the sound
}
}Consumers ask for the interface, not the concrete type:
public class Enemy : MonoBehaviour
{
void Die()
{
ServiceLocator.Get<IAudioService>().PlaySound("enemy_death");
Destroy(gameObject);
}
}Now Enemy depends on IAudioService, not AudioService. You can register a NullAudioService that plays nothing during tests. You can register a completely different implementation without touching any consumer code.
When to Use Which
Singletons win when:
- You’re prototyping and speed matters more than architecture
- The system is truly global and will never need swapping (rare, but it happens)
- The project is small enough that refactoring later costs almost nothing
Service locators win when:
- You need testability, even a little
- Systems should be swappable (debug audio, mock save system, replay input)
- You want to control initialization order explicitly via a bootstrapper
- You need per-scene or per-context scoping, not just global state
The Hybrid (Lazy Singleton Behind a Locator)
You can also register singletons into a service locator for the best of both:
public class GameBootstrapper : MonoBehaviour
{
[SerializeField] private AudioService audioService;
[SerializeField] private SaveService saveService;
void Awake()
{
ServiceLocator.Register<IAudioService>(audioService);
ServiceLocator.Register<ISaveService>(saveService);
}
void OnDestroy()
{
ServiceLocator.Clear();
}
}You still have single instances, but access goes through the locator. This gives you the convenience of global access with the flexibility to swap implementations. It’s the pragmatic middle ground for most Unity projects.
Common Pitfalls
Singleton initialization order. Two singletons that reference each other in Awake() will randomly explode depending on Unity’s script execution order. Service locators with an explicit bootstrapper avoid this entirely because you control what gets registered first.
Static service locators are still global state. A static ServiceLocator class is really just a dictionary-shaped singleton. You’ve traded one form of coupling for a slightly more flexible one. For true decoupling, pass a non-static resolver instance through constructors (see the Object Resolver Pattern note).
Forgetting to unregister. Singletons leak by design (DontDestroyOnLoad). Service locators leak if you forget Unregister or Clear on scene transitions. Always pair registration with cleanup.
The magic: Singletons are fine for jams and small projects. Service locators are fine for anything bigger. The real upgrade is recognizing that both are points on a spectrum, pick the simplest one that won’t make you suffer when the project grows.