Dependency Injection in Swift using a global registry

Tom Zurkan
6 min readOct 18, 2019

--

This is the third article in a four part continuing series on the useful patterns that came out of the development of the Optimizely Swift SDK. The first in the series covered Swift enums with associated types. Then, we covered a new class called AtomicProperty. Here, we will discuss something a little more controversial: dependency injection in Swift.

The challenge in our SDK is that we have a lot of services that span different lifetimes and lifecycles. For instance, we can get a new configuration via json and some services may need to be recreated or re-initialized. The scope of the service also came into play. We have services that span all instances of the sdk client (global scoped), we have services that are sdk client specific singletons (global within client instance scoped), and we have logging that uses a factory for new loggers every time it is injected (instance scoped). In order to manage all of these lifetimes and cycles for these objects, I decided to create a package level global registry.

Implementation

The registry became a key mechanism to share global instances, factories, and other service lifetimes. Below is the code for the registry along with bindings. As you can see, it uses the AtomicProperty mentioned in my earlier post so that the registry is thread safe:

class HandlerRegistryService {  static let shared = HandlerRegistryService()  // rather than use the type and always compare type, we compare type name.  struct ServiceKey: Hashable {
var service: String
var sdkKey: String?
}
var binders: AtomicProperty<[ServiceKey: BinderProtocol]> = {
var binders = AtomicProperty<[ServiceKey: BinderProtocol]>()
binders.property = [ServiceKey: BinderProtocol]()
return binders
}()

private init() {
}
// here we add the register binding. This could also have been done in a performAtomic since we check for nil before setting.
func registerBinding(binder: BinderProtocol) {
let sk = ServiceKey(service: “\(type(of: binder.service))”, sdkKey: binder.sdkKey)
if binders.property?[sk] != nil {
// do nothing
} else {
binders.property?[sk] = binder
}
}
func injectComponent(service: Any, sdkKey: String? = nil, isReintialize: Bool=false) -> Any? {
var result: Any?
// first look up global. Then look up if there is a local.
let skLocal = ServiceKey(service: “\(type(of: service))”, sdkKey: sdkKey)
let skGlobal = ServiceKey(service: “\(type(of: service))”, sdkKey: nil)
let binderToUse = binders.property?[skLocal] ?? binders.property?[skGlobal]
if var binder = binderToUse {
if isReintialize && binder.strategy == .reCreate {
binder.instance = binder.factory()
result = binder.instance
} else if let inst = binder.instance, binder.isSingleton {
result = inst
} else {
let inst = binder.factory()
binder.instance = inst
result = inst
}
}
return result
}
func reInitializeComponent(service: Any, sdkKey: String? = nil) { _ = injectComponent(service: service, sdkKey: sdkKey, isReintialize: true) } func lookupComponents(sdkKey: String)->[Any]? { if let value = self.binders.property?.keys.filter({$0.sdkKey == sdkKey}).compactMap({ self.injectComponent(service: self.binders.property![$0]!.service, sdkKey: sdkKey) }) { return value } return nil }}enum ReInitializeStrategy { case reCreate case reUse}protocol BinderProtocol { var sdkKey: String? { get } var strategy: ReInitializeStrategy { get } var service: Any { get } var isSingleton: Bool { get } var factory:()->Any? { get } //var configure:(_ inst:Any?)->Any? { get } var instance: Any? { get set }}class Binder<T>: BinderProtocol { var sdkKey: String? var service: Any var strategy: ReInitializeStrategy = .reCreate var factory: (() -> Any?) = { ()->Any? in { return nil as Any? }} //var configure: ((Any?) -> Any?) = { (_)->Any? in { return nil as Any? }} var isSingleton = false var inst: T? var instance: Any? {
get {
return inst as Any?
}
set {
if let v = newValue as? T {
inst = v
}
}
}
init(service: Any) { self.service = service } func sdkKey(key: String) -> Binder { self.sdkKey = key return self } func singetlon() -> Binder { isSingleton = true return self } func reInitializeStrategy(strategy: ReInitializeStrategy) -> Binder { self.strategy = strategy return self } func using(instance: T) -> Binder { self.inst = instance return self } func to(factory:@escaping () -> T?) -> Binder { self.factory = factory return self }}

Usage

extension HandlerRegistryService {  func injectLogger(sdkKey: String? = nil, isReintialize: Bool=false) -> OPTLogger? {    return injectComponent(service: OPTLogger.self, sdkKey: sdkKey, isReintialize: isReintialize) as! OPTLogger?  }  func injectNotificationCenter(sdkKey: String? = nil, isReintialize: Bool=false) -> OPTNotificationCenter? {    return injectComponent(service: OPTNotificationCenter.self, sdkKey: sdkKey, isReintialize: isReintialize) as! OPTNotificationCenter?  }  func injectDecisionService(sdkKey: String? = nil, isReintialize: Bool=false) -> OPTDecisionService? {  return injectComponent(service: OPTDecisionService.self, sdkKey:    sdkKey, isReintialize: isReintialize) as! OPTDecisionService?  }  func injectEventDispatcher(sdkKey: String? = nil, isReintialize: Bool=false) -> OPTEventDispatcher? {    return injectComponent(service: OPTEventDispatcher.self, sdkKey: sdkKey, isReintialize: isReintialize) as! OPTEventDispatcher?  }  func injectDatafileHandler(sdkKey: String? = nil, isReintialize: Bool=false) -> OPTDatafileHandler? {    return injectComponent(service: OPTDatafileHandler.self, sdkKey: sdkKey, isReintialize: isReintialize) as! OPTDatafileHandler?  }}

Registration looks something like this:

let binder: Binder = Binder<OPTLogger>(service: OPTLogger.self).to(factory: type(of: logger).init)//Register my logger service.HandlerRegistryService.shared.registerBinding(binder: binder)// this is bound a reusable singleton. so, if we re-initalize, we will keep this.HandlerRegistryService.shared.registerBinding(binder: Binder<OPTNotificationCenter>(service: OPTNotificationCenter.self).singetlon().reInitializeStrategy(strategy: .reUse).using(instance: notificationCenter).sdkKey(key: sdkKey))

Some things of note are:

  1. The SDK key is a per client instance level scope. So, we could have SDK key equal to a value or if SDK key is nil then it is at global scope.
  2. The generic Binder class uses a builder pattern where you set a variable and it returns the binding so you can use function chaining to flavor your binding.
  3. As part of the definition of the interfaces, I included an init. This init which must be implemented acts as the factory. Below is an example:
@objc public protocol OPTLogger {  /// The log level the Logger is initialized with.  static var logLevel: OptimizelyLogLevel { get set }  /**  * Initialize a new Optimizely Logger instance.  */  init()  /**  Log a message at a certain level.  - Parameter level: The priority level of the log.  - Parameter message: The message to log.  */  func log(level: OptimizelyLogLevel, message: String)  }

You can setup the factory using something like this:

// bind it as a non-singleton. so, we will create an instance anytime injected.// we don’t associate the logger with a sdkKey at this time because not all components are sdkKey specific.let binder: Binder = Binder<OPTLogger>(service: OPTLogger.self).to(factory: type(of: logger).init)//Register my logger service.HandlerRegistryService.shared.registerBinding(binder: binder)

The type(of:obj).init gives you access to the interface init to use as a factory.

4. You can register anything as a injectable service. Let’s take a simple example where we want to always have our integers initialized to 10. We can simply register a binding like the following:

HandlerRegistryService.shared.registerBinding(binder: Binder<Int>(service: Int.self).to(factory:{ Int(10) }))var value = HandlerRegistryService.shared.injectComponent(service: Int.self) as! Intprint(value) // prints 10value += 5print(value) // prints 15

With the example above I would wrap that in a factory that a could inject different values depending on runtime or other conditions.

Mocking/Stubbing

The inject registry became really useful for stubbing and mocking services while testing. The reason is simple. We have all these services as interfaces but not all of them are overridable or accessible to outside packages.

So, if everything is a registered service, it is really easy to stub/mock parts. For instance, taking the json update I mentioned earlier, you could stub the getting of a json configuration file so that part of the SDK is not tested while testing other logic.

It should be pretty easy to see how you could register services that override the default in your unit tests. So, you can create quick stubs that return mock values and register them. The optimizely client automatically picks it up as it uses these properties in the following fashion:

lazy var logger = OPTLoggerFactory.getLogger() // this factory is just a wrapper for the inject. Notice that it uses lazy loading to load only when accessed.var eventDispatcher: OPTEventDispatcher? {  return HandlerRegistryService.shared.injectEventDispatcher(sdkKey:   self.sdkKey)}

Below is an example:

// create test datafile handlerlet handler = InnerDatafileHandler()//save the cached datafile..let data = OTUtils.loadJSONDatafile(“api_datafile”)handler.saveDatafile(sdkKey: “localcdnTestSDKKey”, dataFile: data!)handler.dataStore.setLastModified(sdkKey: “localcdnTestSDKKey”, lastModified: “1234”)// set the url to use as our datafile download urlhandler.localFileUrl = fileUrlHandlerRegistryService.shared.registerBinding(binder: Binder<OPTDatafileHandler>(service: OPTDatafileHandler.self).using(instance: handler).singetlon().sdkKey(key: “localcdnTestSDKKey”).reInitializeStrategy(strategy: .reUse))let client = OptimizelyClient(sdkKey: “localcdnTestSDKKey”)

We just register a binding before we instantiate a OptimizelyClient instance.

Conclusion

The HandlerRegistryService is a local package and is not available for SDK users. It does all its work behind the scenes. The logger factory is just a wrapper so that the SDK users can inject a logger. You could create factories for any kind of service you wanted injected and use the factory as the source of different injections. You could even inject the factory functionality. :)

I hope that this article helps you. The registry became very useful for sharing resources, singletons, and exposing factories without the consumer having to worry about how things were done. I also found that having a registry was really helpful for testing. The Optimizely Swift SDK is open source if you would like to dive in a little deeper. Happy coding!

--

--

Tom Zurkan

Engineer working on full stack. His interests lie in modern languages and how best to develop elegant crash proof code.