lundi 14 mars 2022

What is a good design pattern approach for a somewhat dynamic dependency injection in Swift

Let's say there are three components and their respective dynamic dependencies:

struct Component1 {
    let dependency1: Dependency1

    func convertOwnDependenciesToDependency2() -> Dependency2
}

struct Component2 {
    let dependency2: Dependency2
    let dependency3: Dependency3

    func convertOwnDependenciesToDependency4() -> Dependency4
}

struct Component3 {
    let dependency2: Dependency2
    let dependency4: Dependency4

    func convertOwnDependenciesToDependency5() -> Dependency5
}

Each of those components can generate results which can then be used as dependencies of other components. I want to type-safely inject the generated dependencies into the components which rely on them.

I have several approaches which I already worked out but I feel like I am missing something obvious which would make this whole task way easier.

The naive approach:

let component1 = Component1(dependency1: Dependency1())

let dependency2 = component1.convertOwnDependenciesToDependency2()
let component2 = Component2(dependency2: dependency2, dependency3: Dependency3())

let dependency4 = component2.convertOwnDependenciesToDependency4()
let component3 = Component3(dependency2: dependency2, dependency4: dependency4)

let result = component3.convertOwnDependenciesToDependency5()

Now I know that you could just imperatively call each of the functions and simply use the constructor of each component to inject their dependencies. However this approach does not scale very well. In a real scenario there would be up to ten of those components and a lot of call sites where this approach would be used. Therefore it would be very cumbersome to update each of the call sites if for instance Component3 would require another dependency.

The "SwiftUI" approach:

protocol EnvironmentKey {
    associatedtype Value
}

struct EnvironmentValues {

    private var storage: [ObjectIdentifier: Any] = [:]

    subscript<Key>(_ type: Key.Type) -> Key.Value where Key: EnvironmentKey {
        get { return storage[ObjectIdentifier(type)] as! Key.Value }
        set { storage[ObjectIdentifier(type)] = newValue as Any }
    }
}

struct Component1 {
    func convertOwnDependenciesToDependency2(values: EnvironmentValues) {
        let dependency1 = values[Dependency1Key.self]
        // do some stuff with dependency1
        values[Dependency2Key.self] = newDependency
    }
}

struct Component2 {
    func convertOwnDependenciesToDependency4(values: EnvironmentValues) {
        let dependency2 = values[Dependency2Key.self]
        let dependency3 = values[Dependency3Key.self]
        // do some stuff with dependency2 and dependency3
        values[Dependency4Key.self] = newDependency
    }
}

struct Component3 {
    func convertOwnDependenciesToDependency5(values: EnvironmentValues) {
        let dependency2 = values[Dependency2Key.self]
        let dependency4 = values[Dependency4Key.self]
        // do some stuff with dependency2 and dependency4
        values[Dependency5Key.self] = newDependency
    }
}

But what I dislike with this approach is that you first of all have no type-safety and have to either optionally unwrap the dependency and give back an optional dependency which feels odd since what should a component do if the dependency is nil? Or force unwrap the dependencies like I did. But then the next point would be that there is no guarantee whatsoever that Dependency3 is already in the environment at the call site of convertOwnDependenciesToDependency4. Therefore this approach somehow weakens the contract between the components and could make up for unnecessary bugs.

Now I know SwiftUI has a defaultValue in its EnvironmentKey protocol but in my scenario this does not make sense since for instance Dependency4 has no way to instantiate itself without data required from Dependency2 or Depedency3 and therefore no default value.

The event bus approach

enum Event {
    case dependency1(Dependency1)
    case dependency2(Dependency2)
    case dependency3(Dependency3)
    case dependency4(Dependency4)
    case dependency5(Dependency5)
}

protocol EventHandler {
    func handleEvent(event: Event)
}

struct EventBus {
    func subscribe(handler: EventHandler)
    func send(event: Event)
}

struct Component1: EventHandler {
    let bus: EventBus
    let dependency1: Dependency1?

    init(bus: EventBus) {
        self.bus = bus
        self.bus.subscribe(handler: self)
    }
    
    func handleEvent(event: Event) {
        switch event {
        case let .dependency1(dependency1): self.dependency1 = dependency1
        }

        if hasAllReceivedAllDependencies { generateDependency2() }
    }
    
    func generateDependency2() {
        bus.send(newDependency)
    }
}

struct Component2: EventHandler {
    let dependency2: Dependency2?
    let dependency3: Dependency3?
    
    init(bus: EventBus) {
        self.bus = bus
        self.bus.subscribe(handler: self)
    }
    
    func handleEvent(event: Event) {
        switch event {
        case let .dependency2(dependency2): self.dependency2 = dependency2
        case let .dependency3(dependency3): self.dependency3 = dependency3
        }

        if hasAllReceivedAllDependencies { generateDependency4() }
    }
    
    func generateDependency4() {
        bus.send(newDependency)
    }
}

struct Component3: EventHandler {
    let dependency2: Dependency2?
    let dependency4: Dependency4?
    
    init(bus: EventBus) {
        self.bus = bus
        self.bus.subscribe(handler: self)
    }
    
    func handleEvent(event: Event) {
        switch event {
        case let .dependency2(dependency2): self.dependency2 = dependency2
        case let .dependency4(dependency4): self.dependency4 = dependency4
        }

        if hasAllReceivedAllDependencies { generateDependency5() }
    }
    
    func generateDependency5() {
        bus.send(newDependency)
    }
}

I think in terms of type-safety and "dynamism" this approach would be a good fit. However to check if all dependencies were received already to start up the internal processes feels like a hack somehow. It feels like I am misusing this pattern in some form. Furthermore I think this approach may be able to "deadlock" if some dependency event was not sent and is therefore hard to debug where it got stuck. And again I would have to force unwrap the optionals in generateDependencyX but since this function would only get called if all optionals have a real value it seems safe to me.

I also took a look at some other design patterns (like chain-of-responsibility) but I couldn't really figure out how to model this pattern to my use-case.

My dream would be to somehow model a given design pattern as a result builder in the end so it would look something like:

FinalComponent {
    Component1()
    Component2()
    Component3()
}

And in my opinion result builders would be possible with the "SwiftUI" and the event bus approach but they have the already described drawbacks. Again maybe I am missing an obvious design pattern which is already tailored to this situation or I am just modeling the problem in a wrong way. Maybe someone has a suggestion.

Aucun commentaire:

Enregistrer un commentaire