square / Cleanse

Lightweight Swift Dependency Injection Framework
Other
1.78k stars 90 forks source link

What is the right structure for multi-module projects? #172

Closed soniccat closed 3 years ago

soniccat commented 3 years ago

Hi. I've tried this lib in iOS and got a question about problems people have with using subcomponents in multi-module projects in Android. This article is a good example: https://proandroiddev.com/using-dagger-in-a-multi-module-project-1e6af8f06ffc. In short, Android devs use "dependencies" feature from Dagger2 instead. They create a separate dependency interface and pass it to create new a feature component. That allows to remove the connection between the AppComponent and a Sub Component. There is my example: https://github.com/soniccat/WordTeacher/blob/main/androidApp/src/main/java/com/aglushkov/wordteacher/androidApp/features/di/DefinitionsComponent.kt. How would you do the same with Cleanse?

Probably for Cleanse that's not a problem and you use it in multi-module projects without any issues. Could you explain the right way of using Cleanse in this case?

sebastianv1 commented 3 years ago

@soniccat I don't quite follow what you're asking and the connection between subcomponents and multi-module projects. Do you have an example or sample project with Cleanse to describe what you're trying to accomplish?

soniccat commented 3 years ago

@sebastianv1 Let me paraphrase. I'm about the difference between using subcomponents and dependencies in Dagger2. Here a good explanation about these 2 approaches: https://stackoverflow.com/a/30135139/191027. I got how to do "subcomponents" approach using Cleanse but I didn't find the way for "component dependencies" approach. In my opinion "component dependencies" approach is the right way to do DI in multi-module projects and that's why I was interested in figuring out how to do it using Cleanse.

I've researched the topic a bit. I created a project with several sub projects. The idea was to put a feature in a different project. I started with creating a subcomponent per project here (subcomponents git tag): https://github.com/soniccat/CleanseWithMultiModuleTest/tree/subcomponents (FeatureA and FeatureB). Then I tried to remove the dependency between AppComponent and FeatureAComponent to achieve "component dependencies" relationship. I read the doc more carefully and figured out that "Assisted Injection Feature" seemed the right way to do the "component dependencies" Dagger2 approach. So, that allowed me to write let featureAVC = factoryA.build(app) instead of let featureAVC = app.featureAVC(). The diff is here https://github.com/soniccat/CleanseWithMultiModuleTest/commit/05737ebcacfa68ce55692775644ad56204a358e0.

Thank you for your interest :)

sebastianv1 commented 3 years ago

@soniccat I think I follow and believe you're asking about the Seed type that you can pass into a subcomponent?

When you create a subcomponent, you can specify this by setting the Seed associated type. For example:

struct Dependencies {
    let email: String
}

struct MySubcomponent: Cleanse.Component {
    typealias Seed = Dependencies
    typealias Root = ...

    // ...
}

And then when you want to build your subcomponent with the ComponentFactory<MySubComponent>, you would be required to pass an instance of Dependencies.

class App {
    let subcomponentFactory: ComponentFactory<MySubcomponent>
    // ...
    func onTap() {
        let dependencies = Dependencies(email: "me@gmail.com")
        let subcomponentRoot = subcomponentFactory.build(dependencies)
    }
}

Assisted Factory solves a similar issue except that whatever you pass into the build function dependencies aren't stored into the dependency graph while the Seed on a subcomponent is bound into the subgraph for their subcomponent. Hopefully this helps!

soniccat commented 3 years ago

@sebastianv1 yeah, that what I ended up with here https://github.com/soniccat/CleanseWithMultiModuleTest. As I wasn't aware of Guice, "Assisted Injection" term was confusing for me. But now everything is clear.

Thanks for your help

soniccat commented 3 years ago

@sebastianv1 sorry for coming back to the same topic. Is it possible now to somehow attach the properties of a seed into a root component dependency graph?

Now I have DefinitionsDeps argument in the init of DefinitionsViewController and manually create DefinitionsVM from DefinitionsDeps. Is it possible to somehow get a newly created DefinitionsVM in the init of DefinitionsViewController without changing DefinitionsDeps and the Rood and the Seed of DefinitionsComponent?:

public protocol DefinitionsDeps {
    var connectivityManager: ConnectivityManager { get }
    var wordRepository: WordRepository { get }
    var idGenerator: IdGenerator { get }
}

public struct DefinitionsComponent: RootComponent {
    public typealias Root = DefinitionsViewController
    public typealias Seed = DefinitionsDeps

    public static func configureRoot(binder bind: ReceiptBinder<DefinitionsViewController>) -> BindingReceipt<DefinitionsViewController> {
        bind.to(factory: DefinitionsViewController.init)
    }

    public static func configure(binder: Binder<Unscoped>) {
    }
}

public class DefinitionsViewController: UIViewController, UICollectionViewDelegateFlowLayout {
    let vm: DefinitionsVM

    init(deps: DefinitionsDeps) {
        let vm = DefinitionsVM(
            connectivityManager: deps.connectivityManager,
            wordRepository: deps.wordRepository,
            idGenerator: deps.idGenerator,
            state: DefinitionsVM.State(word: nil)
        )

        self.vm = vm
        super.init(nibName: "DefinitionsViewController", bundle: Bundle.main)
    }
...
}

Now I want to achieve this:

public class DefinitionsViewController: UIViewController, UICollectionViewDelegateFlowLayout {
    let vm: DefinitionsVM

    init(vm: DefinitionsVM) {
        self.vm = vm
        super.init(nibName: "DefinitionsViewController", bundle: Bundle.main)
    }
...
}

and I'd expect that I'd need to add something like that... but that's just my assumption:

        binder.bind(DefinitionsVM.self).to {
            DefinitionsVM(
                connectivityManager: $0,
                wordRepository: $1,
                idGenerator: $2,
                state: DefinitionsVM.State(word: nil))
        }
sebastianv1 commented 3 years ago

@soniccat That's exactly correct! You'd simply create a binding for DefinitionsVM. The Seed type DefinitionsDeps is available within your object graph.

So in your DefinitionsComponent you would add the binding to the configure(binder:) function

public struct DefinitionsComponent: RootComponent {
    public typealias Root = DefinitionsViewController
    public typealias Seed = DefinitionsDeps

    public static func configureRoot(binder bind: ReceiptBinder<DefinitionsViewController>) -> BindingReceipt<DefinitionsViewController> {
        bind.to(factory: DefinitionsViewController.init)
    }

    public static func configure(binder: Binder<Unscoped>) {
        binder
            .bind(DefinitionsVM.self)
            .to { (seed: DefinitionsDeps) in
                return DefinitionsVM(connectivityManager: seed.connectivityManager, wordRepository: seed.wordRepository, idGenerator: seed.idGenerator, state: DefinitionsVM.State(word: nil))
    }
}

This binding would you allow you to directly inject this into your view controller. Once an object is "bound" into a a component, you may freely inject it anywhere within that component or a subcomponent can inject it. However, a parent subcomponent cannot inject a dependency that is bound from within a subcomponent.

soniccat commented 3 years ago

@sebastianv1 wow, that works, thanks