I have talked to developers who think Service Locators are a great solution to the dependency injection explosion issue and others who think they are a terrible idea to be avoided at all costs. In this post I want to explore both points of view, looking at the good and the bad use cases for Service Locators and to help you decide if they are a good fit for your pattern toolbox.
Please visit the Sample Code Github Repository for a Swift playground that contains the code snippets shown in this article.
What is a Service Locator?
A good place to start is to define what it is we are talking about. I assume most of you know what a Service Locator is, but let’s put down some code so we can start from the same point of view.
Here is a sample of a Service Locator I recently used in a SpriteKit project. It allows either the registration of an object or a recipe that vends a needed object type.
final class ServiceLocator {
private static var services: [ObjectIdentifier: Any] = [:]
private init() { }
static func register<T>(_ recipe: @escaping () -> T) {
services[ObjectIdentifier(T.self)] = recipe
}
static func register<T>(_ object: T) {
services[ObjectIdentifier(T.self)] = object
}
static func resolve<T>() -> T? {
if let object: T = services[ObjectIdentifier(T.self)] as? T {
return object
}
if let recipe: () -> T = services[ObjectIdentifier(T.self)] as? () -> T {
return recipe()
}
return nil
}
static func reset() {
services = [:]
}
}
As you can see, this class is designed to be used as a global static class. The Service Locator would be available at all levels of scope and maintain a single global registry of objects or recipes to vend upon demand.
Service Locator Example Service
Let’s look at an example usage of this Service Locator so we can understand the flow. First we’ll define a sample service protocol and a class that we will be storing in the Service Locator.
// define a sample protocol to store in the service locator
protocol DataServiceProtocol {
var data: String { get set }
}
// define a concrete implementation of the protocol
final class DataService: DataServiceProtocol {
var data: String = ""
init(data: String) {
self.data = data
}
}
Registering with the Service Locator
Now that we have a service defined, we need to register an instance with the Service Locator. Notice we register the concrete object with the Service Locator as the protocol type. Following this pattern makes it a simple process of replacing this object with a different one that follows the same protocol, and even with a mock object for unit testing.
// create and register the service class
let dataService = DataService(data: "my data")
ServiceLocator.register(dataService as DataServiceProtocol)
Using the “Magic” resolve() Method
// lookup the service in the locator and use it
let service: DataServiceProtocol? = ServiceLocator.resolve()
print(service?.data ?? "") // prints "my data"
Fetching the proper object type from the Service Locator is as simple as defining the type you want, and calling the resolve()
method. Through the magic of generics, the resolve()
method knows the type you are requesting, and looks up that type in its internal storage to return.
Other Service Locator Implementations
This is not the only pattern you can use for a Service Locator. Another style of implementation would be to require the user of the Service Locator to instantiate an instance to use. Internally this local instance may be either self-contained, or may have access to a global set of registrations.
Advantages in Using a Service Locator
Here are a few potential advantages of using the Service Locator in your project.
Reducing init() Clutter
Some might say that one of the primary uses of the Service Locator is to reduce the “clutter” caused in the init()
method due to dependency injection. I have personally seen init()
methods that take over two dozen parameters due to passing in various services and managers. Let’s look at a quick example.
protocol DataService { }
protocol DatabaseService { }
protocol NetworkService {
func fetchData()
}
// manager class specifying all dependencies in the init()
final class DataManager {
init(dataServce: DataService, networkService: NetworkService,
databaseService: DatabaseService) { }
}
// manager class using Service Locator as needed
final class LocatorDataManager {
init() { }
func fetchDataFromNetwork() {
let networkService: NetworkService? = ServiceLocator.resolve()
networkService?.fetchData()
}
}
In this sample code you can see the DataManager
class defines several services it depends on. For this example there are only three, but imagine the size of the init()
as your project grows, especially for those core service or manager classes.
In the second class, the LocatorDataManager
uses a Service Locator as the source of its services. The manager class retrieves the service from the Service Locator where it needs it. This can not only reduce init()
clutter, but can also help with retain cycles as this manager does not retain any of the services it is using.
Eliminating Pass Through Dependencies
Another area the Service Locator helps to improve code readability and control dependency explosion is to eliminate the pass through dependency. Let’s look at an example to better explain what I mean.
protocol DataService { }
// manager class that has to keep a service property it only passes along to the Worker
final class Manager {
private var dataService: DataService
init(dataService: DataService) {
self.dataService = dataService
}
func doSomeWork() {
let worker = Worker(dataService: dataService)
worker.doWork()
}
}
// worker class that has a dependency on a DataService class
final class Worker {
private var dataService: DataService
init(dataService: DataService) {
self.dataService = dataService
}
func doWork() {
// use the DataService here
}
}
As you can see in the code sample above, the Manager
class never uses the dataService
property that is required to initialize it. The Manager
class only uses the dataService
property to create the Worker
object when needed.
If the Worker
class were using a Service Locator to retrieve the DataService
, then the Manager
class would not need a property holding a DataService
instance.
Here is another code sample of our Manager
and Worker
where the Worker
is using a Service Locator instead of dependency injection.
// manager class using a LocatingWorker which uses a ServiceLocator
final class LocatingManager {
init() { }
func doSomeWork() {
let worker = LocatingWorker()
worker.doWork()
}
}
// worker class that uses a ServiceLocator for its dependency on a DataService class
final class LocatingWorker {
init() { }
func doWork() {
let dataService: DataService? = ServiceLocator.resolve()
dataService?.fetchData()
}
}
Notice how much cleaner the init()
methods are, and the LocatingManager
no longer needs to hold onto a DataService
property that it only uses to pass along during creation of the Worker
class.
Providing Clear Service Ownership
One issue I’ve run into several times when passing services around, is the question of ownership. Should this manager class retain a strong reference to the service it uses, or just keep a weak
reference? What if it is a service that depends on another service?
If we use weak
everywhere, then who is the original owner of the shared service? Do we have some “AppInstance” object that creates and retains all the shared services that get passed around through dependency injection? If your argument against the Service Locator is one against singletons or central repositories, then you’ve just recreated that which you oppose. They may be privately managed singletons in your “AppInstance”, but they are still singletons.
Reasons to Avoid the Service Locator
You’ve seen a few of the reasons why Service Locators may be a good fit for your project. Let’s look from the opposing perspective and see why Service Locators may be a bad idea, for any project.
Less Visibility of Dependencies
While reducing init()
clutter may seem a worthy goal on the surface. By doing so you are also reducing the visibility of the dependencies of that class. As an outside observer needing to use such a class, how do you know which other services this class depends on? If these service dependencies were listed in the init()
it would be a very straightforward answer.
Let’s look at the same example code showed earlier and talk about it from a different perspective.
protocol DataService { }
protocol DatabaseService { }
protocol NetworkService {
func fetchData()
}
// manager class specifying all dependencies in the init()
final class DataManager {
init(dataServce: DataService, networkService: NetworkService,
databaseService: DatabaseService) { }
}
// manager class using Service Locator as needed
final class LocatorDataManager {
init() { }
func fetchDataFromNetwork() {
let networkService: NetworkService? = ServiceLocator.resolve()
networkService?.fetchData()
}
}
If you were trying to use the DataManager
class, you can clearly see that it depends on the DataService
, the NetworkService
and the DatabaseService
as they are all required parameters in the init()
method. This is a simple and clear approach to understanding the dependencies of this class.
On the other hand, if you were creating an instance of the LocatorDataManager
you would not know from the init()
method if this manager depended on any other services. The only indication would be to look through the code and see where the Service Locator is being used. If you were using the Service Locator approach you would need to make sure these dependencies were registered before using this class.
Less Control Over Specific Class Instances
Another less obvious problem with using a Service Locator, is that you can generally only store a single instance object per object type.
For example, if you are storing a SQLiteDatabaseService
instance as the general DatabaseServiceProtocol
in the Service Locator, that becomes the only instance you retrieve when looking for a DatabaseServiceProtocol
in the Service Locator. If you also wanted to store a MySQLDatabaseService
with the generic DatabaseServiceProtocol
, you would be unable to.
A related issue is if you wanted to use two or more instances for the same type, you would be unable to store more than a single instance of that same type in a Service Locator. For example, if you had long lived database worker threads that need to fetch the database service. Using a service locator, each of your threads would have to share a single instance of your database service. That approach may be fine for a single globally shared service, but if your service is shorter lived, such as per connection, you would have to find a different approach.
Unit Testing Requires Careful Cleanup
On the surface it seems like using a Service Locator might make unit testing much easier. And in most cases it may. However you have to keep in mind that a Service Locator is typically a singleton who’s state will survive your unit test. So if you are registering services for a unit test, you have to be very careful to unregister those service after each test completes.
This isn’t so much a disadvantage for the Service Locator, but more a cautionary tale. In the sample code for this post, the ServiceLocator
class has the reset()
method for just this use case.
Alternatives to Using a Service Locator
If you have decided the typical singleton/static Service Locator does not fit with your needs, what are the alternatives? Let’s look through a short list of other approaches to solve your dependency needs.
Dependency Injection
An obvious alternative to the Service Locator is using dependency injection. Yes in some situations it can have its own share of issues, but using dependency injection clearly lays out the dependencies for a class in its constructor.
// manager class using dependency injection through the constructor
final class DataManager {
private weak var dataService: DataService?
private weak var databaseService: DatabaseService?
private weak var networkService: NetworkService?
init(dataServce: DataService, networkService: NetworkService,
databaseService: DatabaseService) {
self.dataService = dataServce
self.networkService = networkService
self.databaseService = databaseService
}
func fetchDataFromNetwork() {
networkService?.fetchData()
}
}
As you can see in the sample code above, the dependencies of the DataManager
class are clearly defined in its constructor. Any user of this class will have a clear understanding of the other class instances they will need to have available to make use of this class.
Service Locator Injection
A sort of middle ground would be for the DataManager
class to take an instance of a Service Locator as a dependency. Let’s look at a quick example of what I mean.
// some sample service protocol definitions
protocol DataService: class { }
protocol DatabaseService: class { }
protocol NetworkService: class {
func fetchData()
}
// manager class taking a service locator instance as a dependency
final class DataManager {
private weak var serviceLocator: ServiceLocatorInstance?
init(serviceLocator: ServiceLocatorInstance) {
self.serviceLocator = serviceLocator
}
func fetchDataFromNetwork() {
let networkService: NetworkService? = serviceLocator?.resolve()
networkService?.fetchData()
}
}
In this sample code you can see the ServiceLocatorInstance
is stored as a dependency of the DataManager
class and is used to fetch the NetworkService
when it is needed.
While this does not exactly outline all the classes the manager is dependent on, it does indicate that a Service Locator is involved up front, rather than the Service Locator being buried deep in the class where it is used.
Final Words
Some could argue that what one developer sees as an advantage for the Service Locator, another sees as a disadvantage. Yes, reducing init()
clutter can hide dependencies. And what might be important to an enterprise software project with multiple teams involved, may not be as important to a two man dev team.
Conveying a class’s dependencies through the code is only one approach. The end goal should be software that is easy to approach, easy to understand and easy to maintain.
I hope this discussion on the Service Locator has helped you to see both schools of thought on this somewhat controversial software pattern. Maybe it has a place in your next project, or maybe you see better why it should be avoided altogether.