Dependency Injection and the Tree of Knowledge
On Twitter, Manuel Schulze (@zet_manu shared the Swift package Resolver that does dependency injection in a very convenient way with little boilerplate thanks to property wrappers:
class BasicInjectedViewController: UIViewController {
@Injected var service: XYZService
@LazyInjected var service2: XYZLazyService
@WeakLazyInjected var service3: XYZAnotherLazyService?
}
Mr Dr Dominik Hauser replied, and that’s how it entered my Twitter timeline. I was curious why people use packages like this – I know the concept from Java, but have always found constructor injection and maybe a Service Locator here and there to suffice.
Example: Pass-Through Injections
Manuel replied with an interesting example that shows a real pain point and code smell: passing on dependencies from A (over B and C and D) to E, say in a view controller hierarchy where you go from one scene to the next:
class First {
private let service: SomeRandomService
init(service: SomeRandomService) {
self.service = service
service.doSomething()
}
func createNext() {
// passes the service down the tree
_ = Second (service: service)
}
}
class Second {
// Does not need the service
private let service: SomeRandomService
init(service: SomeRandomService) {
self. service = service
}
func createNext() {
// passes the service down the tree
_ = Third(service: service)
}
}
class Third {
// Does not need the service
private let service: SomeRandomService
init(service: SomeRandomService) {
self.service = service
}
func createNext () {
// passes the service down the tree
_ = Forth (service: service)
}
}
class Forth {
private let service: SomeRandomService
init(service: SomeRandomService) {
self. service = service
service.doSomething()
}
// ...
}
It sucks, I agree.
If you run into this, I believe it’s a good call to stop and question the approach. Why is the service passed on? That’s useless for all intermediate steps. (That’s the code smell part.)
With a centralized Service Locator and property wrappers, you get direct access and get rid of the code smell:
class ForthResolver {
@Injected private var service: SomeRandomService
init() {
service.doSomething()
}
}
That’s much more direct!
You also don’t need to create FourthResolver
from ThirdResolver
– they don’t pass stuff around anymore, so you’re free to solve this differently. (Manuel did keep the createNext()
part in his example, but I believe that could be distracting from the problem we wanted to focus on.)
Extracting Knowledge
My personal solution would be different.
Using a Service Locator of any kind is similar to using global variables and singletons. There’s a place for this.
But it’s not the only solution to the problem at hand!
If we have these 4 controllers, and they are in a programmatic sequence (similar to a Storyboard), then there’s reason to extract the sequence itself as a thing, i.e. an object.
class ExtractedSequence {
func setUp() {
// Shared service:
let service = SomeRandomService()
// Inject service to controllers:
let first = First(service: service)
let second = Second()
let third = Third()
let fourth = Fourth(service: service)
// Wire up the sequence
first.next = second
second.next = third
third.next = fourth
}
}
// The "next scene" logic, sketched as a protocol:
protocol Scene {
var next: Scene? { get set }
}
class First: Scene { ... }
class Second: Scene { ... }
class Third: Scene { ... }
class Fourth: Scene { ... }
Manuel described the situation as a “tree” (again, like a branch in a Storyboard) – so my suggestion is: extract the tree-ness into its own thing.
Or ‘reify the sequential connection’. Or whatever you want to call this.
Instead of implicit logic via hops from A to B to C to …, we make the sequence explicit.
In the past, I’ve noticed this has been called “Wireframes” in the context of VIPER. Dominik used the term “Coordinator”, which I attribute to Soroush Khanlou, and which was changed over the years to mean many things, but it seems to be compatible with my ExtractedSequence
above.
Injecting the shared service then isn’t that much of a hassle anymore, either.
For lots of shared services, it can pay off to bundle them together in a Context
object. (I just again read about this a couple weeks ago but didn’t bookmark it, so pointers would be welcome in the comments!)
class SceneContext {
let someRandomService = SomeRandomService()
// ... more services ...
// ... and also:
var sharedState: Int = 4
}
let context = SceneContext()
let first = First(context: context)
let second = Second(context: context)
let third = Third(context: context)
let fourth = Fourth(context: context)
The shared state in the context might be interesting to all scenes – so it could make sense to add an init(context: SceneContext)
requirement to a protocol implemented by all scenes here. Or maybe not.
Instead of a reference type context with implicitly mutable state, you might also want to explore options to use value type contexts and pass on a changed context copy to the next scene.
Either way though, there’s a shared context that was not explicit before, but now is.
Service Locator Pattern and Dependency Injection Bad?
No!
Not bad. It’s just not the tool I’d use for a job like this.
But things can and do get much worse sometimes, and then constructor injection might not work out as well anymore. And you’re under pressure. And it’s a start-up that needs an app quick. Then a shared repository of services that essentially hides global variables isn’t the worst approach.
Rehashing the reasons why I reach for some tools and not others was a valuable exercise for me tha I wanted to share. I don’t believe there’s anything wrong with the Resolver package or Manuel’s example per se. Just be aware there’s other solutions you could reach for.