Fighting over-usage of "dynamic" dependency without introducing infinite loop
aurelienp opened this issue · 5 comments
As I start using Needle in my project, I frequently face the dilemna of having to heavily rely on "dynamic" dependency injection in order to avoid infinite loops. As a result, the code feels like it's not fully embracing the Needle approach.
I think the best way to explain this problem is with the following example. MyChildVC
is a child VC of MyVC
. The MyChildVC
instance is created in MyVC.init
(it could probably be done differently but it's off-topic). MyChildVC
depends on a property built by MyVC
itself.
I wonder if I'm missing something obvious to solve this problem and/or if there's a recommended approach to workaround this for a codebase that must scale.
// MARK: Parent component
protocol MyDependency: Dependency {}
class MyComponent: Component<MyDependency>, MyVCBuilder {
var myVC: MyVC {
shared {
MyVC(
aStreamInitialValue: 0,
aChildVCBuilder: myChildComponent
)
}
}
var myChildComponent: MyChildComponent {
MyChildComponent(parent: self)
}
var aStreamInitialValueSetupByParent: Int {
// Refers to `myVC` before init finishes, leading to and infinite loop
myVC.aStreamSource
}
var aStreamSetupByParent: AnyPublisher<Int, Never> {
// Refers to `myVC` before init finishes, leading to and infinite loop
myVC.$aStreamSource
}
}
protocol MyVCBuilder {
var myVC: MyVC { get }
}
class MyVC: UIViewController {
@Published var aStreamSource: Int
let childVC: MyChildVC
init(
aStreamInitialValue: Int,
aChildVCBuilder: MyChildVCBuilder
) {
aStreamSource = aStreamInitialValue
// The "problem" illustrated with this example comes from the facts that:
// - this builder is called in `MyVC.init` (but even if it wasn't, I'd expect the external code to be defensive)
// - `aStream`, dependency needed by the child, is setup inside MyVC
//
// In a non-Needle world, I would pass `aStreamSource` and `$aStreamSource` directly into the builder here.
// (eventually relying on "dynamic" dependency if I understand your nomenclature properly).
// But it doesn't feel very Needle-y. Eventually, it means passing an heavy amount of dependencies "manually",
// somewhat defeating the Needle system, and it's only to workaround an infinite loop.
childVC = aChildVCBuilder.myChildVC
super.init(nibName: nil, bundle: nil)
}
}
// MARK: Child component
protocol MyChildDependency: Dependency {
var aStreamInitialValueSetupByParent: Int
var aStreamSetupByParent: AnyPublisher<Int, Never>
}
class MyChildComponent: MyChildVCBuilder {
var myChildVC: MyChildVC {
MyChildVC(
aStreamInitialValue: dependency.aStreamInitialValueSetupByParent
aStream: dependency.aStreamSetupByParent
)
}
}
protocol MyChildVCBuilder {
var myChildVC: MyChildVC { get }
}
class MyChildVC: UIViewController {
let aStreamInitialValue: Int
let aStream: AnyPublisher<Int, Never>
init(aStreamInitialValue: Int, aStream: aStream) {
self.aStreamInitialValue = aStreamInitialValue
self.aStream = aStream
super.init(nibName: nil, bundle: nil)
}
}
EDIT: I corrected the aStreamSetupByParent
type issue that you mentioned in your first comment @rudro. Thanks for pointing that out.
We use RxSwift heavily so my Combine API calls may be a bit off. I think the setup in the Child seems fine to me I ignore the one of type Int
. Let's focus on the aStream
var. That seems good. So the parent creates a subset and the child get's access to it using Needle. So in the parent I would have expected to see something like :
var aStreamSetupByParent: AnyPublisher<Int, Never> { // note: type must match what is asked for by the child dependency protocol
return shared { PassthroughSubject<CGPoint, Never>().eraseToAnyPublisher() } // note: the shared makes it so you get the same instance each time you access the var
}
Also, it's a bit confusing, but streams are not considered "dynamic" dependencies as the instances of the streams are known at compile time. "Dynamic" dependencies are say a passcode the user types in and then you would pass that Int into the constructor of a component class.
If I understand correctly, you are saying that the pattern of forwarding to myVC
in MyComponent.aStreamSetupByParent
is not correct. Instead, components should be in charge of creating the instances.
I guess this also means you encourage injecting such streams in MyVC.init
? But doesn't that force us to change MyVC
implementation, hence breaking the promise that Needle will not interfere with code that is outside of Component?
Also, it's a bit confusing, but streams are not considered "dynamic" dependencies as the instances of the streams are known at compile time.
Sorry if my message was confusing. The code I shared was to illustrate the infinite-loop issue, hence does not rely on dynamic dependency. Could you follow the trace after the call to aChildVCBuilder.myChildVC
and see how it end up in an infinite loop (because shared
relies on a recursive lock I guess).
Right, the stream instance should be created at the parent. e.g here https://github.com/uber/needle/blob/master/Sample/MVC/TicTacToe/Sources/Root/RootComponent.swift#L27.
Then a child VC will ask for this stream and constructor inject it (into the VC) as seen in this file: https://github.com/uber/needle/blob/master/Sample/MVC/TicTacToe/Sources/ScoreSheet/ScoreSheetComponent.swift
I'm not sure I understand why this breaks the promise Needle will interfere with code outside of the Component.
I'm not sure I understand why this breaks the promise Needle will interfere with code outside of the Component.
You're right, indeed. It seems arbitrary to say that when it comes to injecting streams rather than injecting other sort of things such as VC builders.
It just seems somewhat unnatural to me that so much ends up being injected in a VC, just because a VC lower in the hierarchy depends on something. But that's a matter of habit and nothing wrong with this from a theoretical point of view. Needle is encouraging to be a DI maximalist :)
Thanks for all the replies. I'll mark as resolved.