In this article, I'll explain what the Service Locator is, how to implement it, and its practical use within Unity. We'll also explore some of the drawbacks associated with this pattern. By the end of this article, you should have a good grasp of the pattern, its strengths and shortcomings, and how to implement and utilize it in your Unity projects.
Resources
- GitHub — Link to the code in this article
What is the Problem
In Unity, MonoBehaviour classes lack constructors, which means each MonoBehaviour class is responsible for creating its own dependencies that are not readily provided through Unity's internal Dependency Injection mechanism, such as dragging and dropping a reference to a MonoBehaviour in the inspector.
When we delve into the issues related to Unity's programming model, several significant problems emerge. One of the key issues is its lack of scalability. In essence, if a class relies on another class with a fixed implementation, you're essentially violating the Dependency Inversion Principle. As the project grows, your project will be a spaghetti that you have no other choice to eat in one daunting bite.
Furthermore, when classes not only use their dependencies but also attempt to create those dependencies (which might, in turn, have their own dependencies), it contradicts the Single Responsibility Principle. Single responsibility tries to achieve modularity of the codebase. It promotes having atomic implementations that are meant to be reused just like lego bricks. Creating an object can be very complex due to recursing dependencies and could easily include operations that is so far away from responsibilty of your class. Thus, dependency creation should be delegated to other mechanisms as much as it makes sense.
As Angela Duckworth aptly put it, "Principles are the unyielding bedrock upon which greatness is built, tested, and proven." We certainly don't want to construct our projects on an unstable foundation.
Let's move beyond memorization and clarify this issue with an example:
public class Enemy : MonoBehaviour
{
INPCInfoLoaderService loader;
EnemyInfo info;
void Start()
{
loader = new NPCInfoLoaderFromResources("path");
info = loader.LoadInfo(1);
}
}In this code, the Enemyclass is responsible for creating its own dependency, a concrete implementation of INPCInfoLoaderService. So, why declare loaderas an interface when there's no polymorphism involved? What happens if, in a production environment, we need to fetch information from the web? We'd have to modify the Enemy class, which is far from ideal. The Service Locator pattern comes into play to address this issue.
Before we dive into implementing the pattern, it's essential to clarify a common misconception about the Service Locator. Some consider it a superior alternative to the Singleton pattern, but in reality, it inherits one of the main drawbacks of Singleton: hiding the class dependencies. While Service Locator can be used to implement Singleton, it doesn't completely resolve the issue; it merely shifts it elsewhere. Nevertheless, it does offer a partial solution to the problem of dependency inversion.
What is the Service Locator?
The Service Locator is essentially a persistent cache that stores references to class instances and provides them when requested. In simple terms, it delegates the task of creating dependencies to another class. Implementing the Service Locator pattern is straightforward and can be achieved with just a few lines of code.
public static class ServiceLocator
{
private static Dictionary<Type, object> services = new();
public static void RegisterService<T>(T service)
{
services[typeof(T)] = service;
}
public static T GetService<T>()
{
return (T)services[typeof(T)];
}
}While this code provides a basic concept, it's not the final implementation. Firstly, using a static ServiceLocatorclass is not ideal, as it's challenging to unit test and extend its functionality. Secondly, the use of a dictionary to store services isn't thread-safe, so we need to address that. Thirdly, we should handle potential failure cases, such as unregistered services or incorrect types. Patience; we'll address these concerns shortly.
Another point to consider is that, in this example, we're registering an instance of a service, effectively making it a Singleton. If you want a new instance to be created each time you request the service, you'd need to register a factory instead of an instance. We won't cover this here, but it's a topic we can explore in a future article on the Factory pattern.
The Design
1. Service Locator — Acts as the intermediary between the client and the service's implementation. It provides service implementations to the client on request.
2. Client — The consumer of the service, typically a class that requires access to the service.
3. Cache — Stores references to services to prevent the creation of new service instances.
4. Service — The actual implementation of the service, which adheres to the service interface.
5. Service Scope — An optional component that can be highly useful. It's a class that encapsulates the Service Locator and offers a way to initialize an instance of it. By extending this class, we can create various Service Locators for different purposes, such as one for production code and another for unit testing.
The Implementation
public interface IServiceLocator
{
/// <summary>
/// Register an instance with the given type T.
/// </summary>
/// <param name="service">The instance to register.</param>
/// <typeparam name="T">The type that this instance will be registered with.</typeparam>
/// <exception cref="ArgumentNullException">Thrown if the given instance is null.</exception>
/// <exception cref="ArgumentException">
/// Thrown if there is another instance registered with the same type.
/// </exception>
void Register<T>(T service);
/// <summary>
/// Register an instance with the given type T.
/// </summary>
/// <param name="type">The type that this service will be registered with.</param>
/// <param name="service">The instance to register.</param>
/// <exception cref="ArgumentNullException">Thrown if the given instance is null.</exception>
/// <exception cref="ArgumentException">
/// Thrown if there is another instance registered with the same type.
/// Also Thrown if the given type is not assignable from the type of the given instance.
/// </exception>
void Register(Type type, object service);
/// <summary>
/// Unregister the service of type T.
/// </summary>
/// <typeparam name="T">T is the type of the service to be unregistered</typeparam>
/// <returns>True if the type is successfully unregistered.</returns>
bool Unregister<T>();
/// <summary>
/// Unregister the service of type T.
/// </summary>
/// <returns>True if the type is successfully unregistered.</returns>
/// <exception cref="KeyNotFoundException">Thrown if there is no instance registered with the type</exception>
/// <exception cref="ArgumentNullException">Thrown if there is no instance registered with the type</exception>
bool Unregister(Type type);
/// <summary>
/// Get the service of type T.
/// </summary>
/// <typeparam name="T">The type of the service to be retrieved.</typeparam>
/// <returns>The instance that is registered with the given interface type T. </returns>
/// <exception cref="KeyNotFoundException">Thrown if there is no instance registered with the type T.</exception>
/// <exception cref="ArgumentNullException">Thrown if there is no instance registered with the type T.</exception>
T Get<T>();
/// <summary>
/// Get the service of type.
/// </summary>
/// <param name="type">The type of the service to be retrieved.</param>
/// <returns>The instance that is registered with the given interface type T. </returns>
/// <exception cref="KeyNotFoundException">Thrown if there is no instance registered with the type</exception>
/// <exception cref="ArgumentNullException">Thrown if there is no instance registered with the type</exception>
object Get(Type type);
/// <summary>
/// Check if the service of type T is registered.
/// </summary>
/// <typeparam name="T">The type of the service to be checked.</typeparam>
/// <returns>True if the service is registered, false otherwise.</returns>
bool IsRegistered<T>();
/// <summary>
/// Check if the service of type T is registered.
/// </summary>
/// <param name="type">The type of the service to be checked.</param>
/// <returns>True if the service is registered, false otherwise.</returns>
/// <exception cref="ArgumentNullException"> Thrown if given type parameter is null.</exception>
bool IsRegistered(Type type);
}Here we have the interface for the Service Locator. It is pretty straightforward. We have methods to register, unregister, and get services. We also have methods to check if a service is registered. We defined the expected behavior of the methods in the XML comments and this is what we have to do while declaring interfaces. This is the contract that the implementation should follow.
Now, let's create a class that implements this interface:
public class ServiceLocator : IServiceLocator
{
// Full implementation can be found in the source code
}You can have various types of ServiceLocators that have different constraints. For example, you can have one InterfaceOnlyServiceLocator that only accepts interface types as instance keys.
As for the usage, let's consider the previous example.
[DefaultExecutionOrder(-1)]
public abstract class RuntimeServiceScopeBase : MonoBehaviour
{
private ServiceLocator locator;
protected virtual void Awake()
{
DontDestroyOnLoad(this);
locator = new ServiceLocator();
Configure(locator);
}
protected abstract void Configure(ServiceLocator locator);
public bool TryGetService<T>(out T service)
{
try
{
service = locator.Get<T>();
return true;
}
catch (Exception e)
{
Debug.LogError($"Failed to get service of type {typeof(T)}: {e.Message}");
service = default;
return false;
}
}
public bool TryGetService(Type type, out object service)
{
try
{
service = locator.Get(type);
return true;
}
catch (Exception e)
{
Debug.LogError($"Failed to get service of type {type}: {e.Message}");
service = default;
return false;
}
}
}This is a very simple implementation of the Service Scope component. It calls the abstract Configure function at the end of the Awake function. This is where the inheriting classes will register the services. Then, the client classes can use one of the get methods to get the services they depend on.
Setting the execution order to -1 will ensure that this component will be initialized before any other component. This is important because we want to make sure that the services are registered before any other component tries to get them.
public class SingletonRuntimeServiceScope : RuntimeServiceScopeBase
{
public static SingletonRuntimeServiceScope Instance { get; private set; }
protected override void Awake()
{
if(Instance != null)
{
return;
}
Instance = this;
base.Awake();
}
protected override void Configure(ServiceLocator locator)
{
locator.Register<INPCInfoLoaderService>(new NPCInfoLoaderFromResources("path"));
}
}
public class Enemy : MonoBehaviour
{
INPCInfoLoaderService loader;
EnemyInfo info;
private void Start()
{
SingletonRuntimeServiceScope.Instance.TryGetService<INPCInfoLoaderService>(out loader);
info = loader.LoadInfo(1);
}
}As you can see, the Enemy class is not responsible for creating its own dependencies. It is only responsible for getting the dependency from the Service Locator. This is a huge improvement. Now, we can change the implementation of the loader without changing the code in the Enemy class. Despite it is also technically possible to change the implementation of INPCInfoLoaderService at runtime (after initialization), this would create a similar behavior to the State Machine pattern. Thus, we prevented this by encapsulating the ServiceLocator. Instead, the registration is handled by the RuntimeServiceScopeBase at initialization, and only Get methods are exposed to clients.
Let's now refer to the obvious problems with this implementation.
In the above implementation, we used a Singleton for accessing the ServiceLocator. This single-handedly prevents us from using different registrations based on the context because we will always get the same instances registered by the singleton. This is why it is said that ServiceLocators are bad for unit testing. Any effort to resolve this will take us closer to the implementation of an IoC Container (That I will talk about in my next article).
Still, I want to bring a simple solution to this problem here without getting distracted. We can have a ServiceScopeProvider that can be configured at runtime to provide different ServiceScopes based on the context. Then we will just ask the ServiceScopeProvider for the ServiceScope and use it to get the services. This way we can have different Services for different contexts.
Let's first turn our ServiceScope into an interface.
public interface IServiceScope
{
bool TryGetService<T>(out T service);
bool TryGetService(Type type, out object service);
}
public abstract class ServiceScopeBase : IServiceScope
{
private IServiceLocator locator;
protected ServiceScopeBase()
{
locator = new ServiceLocator();
Configure(locator);
}
protected ServiceScopeBase(IServiceLocator locator)
{
this.locator = locator;
Configure(locator);
}
protected abstract void Configure(ServiceLocator locator);
public bool TryGetService<T>(out T service)
{
try
{
service = locator.Get<T>();
return true;
}
catch (Exception e)
{
Debug.LogError($"Failed to get service of type {typeof(T)}: {e.Message}");
service = default;
return false;
}
}
public bool TryGetService(Type type, out object service)
{
try
{
service = locator.Get(type);
return true;
}
catch (Exception e)
{
Debug.LogError($"Failed to get service of type {type}: {e.Message}");
service = default;
return false;
}
}
}
public class ServiceContextAServiceScope : ServiceScopeBase
{
protected override void Configure(ServiceLocator locator)
{
locator.Register<INPCInfoLoaderService>(new NPCInfoLoaderFromResources("path"));
}
}
public class ServiceContextBServiceScope : ServiceScopeBase
{
protected override void Configure(ServiceLocator locator)
{
locator.Register<INPCInfoLoaderService>(new NPCInfoLoaderFromWeb("url"));
}
}
public enum ServiceScopeType
{
ServiceContextA,
ServiceContextB
}
[DefaultExecutionOrder(-1)]
public class ServiceScopeProvider : MonoBehaviour
{
[SerializeField] ServiceContext context;
public IServiceScope Scope { get; internal set; }
public static ServiceScopeProvider Instance { get; internal set; }
private void Awake()
{
if (Instance != null)
{
return;
}
Instance = this;
if(serviceScopeType == ServiceScopeType.ServiceContextA)
{
Scope = new ServiceContextAServiceScope();
}
else if(serviceScopeType == ServiceScopeType.ServiceContextB)
{
Scope = new ServiceContextBServiceScope();
}
}
}
public class Enemy : MonoBehaviour
{
private INPCInfoLoaderService loader;
private EnemyInfo info;
public EnemyInfo Info => info;
private void Start()
{
ServiceScopeProvider.Instance.Scope.TryGetService<INPCInfoLoaderService>(out loader);
info = loader.LoadInfo(1);
}
}Our test script will look as follows:
public class ServiceScopeProviderTests
{
public ServiceScopeProviderTests()
{
if(ServiceScopeProvider.Instance != null)
{
Object.DestroyImmediate(ServiceScopeProvider.Instance.gameObject);
}
var serviceScopeProvider = new GameObject("TestServiceScopeProviderHost")
.AddComponent<ServiceScopeProvider>();
serviceScopeProvider.Scope = new MockServiceScope();
ServiceScopeProvider.Instance = serviceScopeProvider;
}
[UnityTest]
public IEnumerator ServiceScopeProviderMockTest()
{
GameObject enemyObject = new GameObject();
enemyObject.AddComponent<Enemy>();
var enemy = enemyObject.GetComponent<Enemy>();
Assert.IsNotNull(enemy.Info);
yield return null;
}
}At first glance, it may seem complex, but in reality, the setup is quite straightforward. We begin with the ServiceScopeProvider, a MonoBehaviour responsible for both delivering the ServiceScope and ensuring the removal of any duplicate instances. It determines the appropriate ServiceScope to provide based on a ServiceScopeType enum, which offers a simple mechanism for customization to suit your specific project requirements.
Within our EnemyTest script, we establish a custom ServiceScope in the constructor, specifically tailored for testing purposes. This custom scope likely registers mock service implementations. What we've accomplished here serves as a fundamental example of the Inversion of Control Containers, illustrating how the Service Locator pattern can be utilized effectively.
Flaws of Service Locator
- Service Locator hides the dependencies of classes. There is no way to find out what dependencies a class has rather than looking at its implementation. This is why Service Locator, according to many, counted as an anti-pattern just like Singleton.
- It is impossible for a client class that uses the Service Locator to retrieve its dependencies to exist without the Service Locator. In this sense, the Service Locator is a concrete dependency itself.
- You might have noticed that we've been working hard to find a polymorphic solution, but we haven't quite got there. Remember when I previously defined the Service Locator as a 'persistent cache that stores references to class instances and provides them when requested'? The key here is the 'when requested' part, which means that client classes are still in charge of finding their dependencies. Whether those dependencies are hiding or out in the open, this pattern isn't the best way to achieve the flexible solution we need.
- What we really need is a system that's easy to customize and takes care of handling our interface dependencies seamlessly. A system where we can clearly define implementations for our dependencies and let it handle everything. This is where Dependency Injection comes into play, and it's all about Inversion of Control. We'll explore these concepts more in my next article.
If you found this article valuable, a round of applause (👏) would be greatly appreciated. It helps the content reach more readers who might find it useful. Feel free to share it with your fellow developers and friends as well. Thank you for your support!
References: