- Nick Daigler
iOS @ Pludo: Reactive Components
Reactive programming is a trendy topic these days, and this has proved especially true in the iOS community over the past few years. Combine was introduced at WWDC 2019 and only added fuel to the fire.
Combine is great; we use it at Pludo for a number of different components and domain-related concepts. But, there are other cases where it's been easier to use our own notions of triggers and observers, rather than Combine, or some other third-party reactive framework.
Triggers and Observers
There are a number of components that use the notion of triggers and observers to react to data flow. Triggers and observers are coordinating components that control the interaction on domain-related models. Other names for these kinds of components include "controller", "interactor", "model controller", and "control boundary". All of these names refer to a similar notion: an object that encapsulates the interaction of domain models and services.
As usual, I'll use a concrete example. Consider scrolling inside a Pludo space. There's going to have to be some pagination at some point. There are a number of components interacting to make the magic happen. The semantics behind the pagination system encapsulate
determining the list's content's offset
determining if we need to paginate
fetching the messages
loading the messages appropriately into memory
The Big Picture
We would like to
trigger message fetching when we need to paginate
observe any new messages that have been fetched
Consider the pseudo-code below.
struct Message {}
protocol FetchMessagesTrigger {
func fetchMessages()
}
protocol NewMessagesObserver {
func bind(completion: @escaping ([Message]) -> Void)
}
Putting It Into Practice
Imagine we had two distinct domain services that each conform to the FetchMessagesTrigger and NewMessagesObserver, respectively. Now, we could have our UI presentation logic depend on a FetchMessagesTrigger, and any underlying infra responsible for appropriately loading messages into memory depend on a NewMessagesObserver.
It might sound scary, but consider the example code below; this is how these components could be wired together via initializer injection.
class MessageFetcher: FetchMessagesTrigger { ... }
class MessageObserver: NewMessagesObserver { ... }
class PresentationLogic {
init(trigger: FetchMessagesTrigger { ... }
...
}
class MessageLoadingInfra {
init(observer: NewMessagesObserver) { ... }
...
}
let trigger = MessageFetcher()
let presentationLogic = PresentationLogic(trigger: trigger)
let observer = MessageObserver()
let loadingInfra = MessageLoadingInfra(observer: observer)
So, in this example, both the MessageFetcher and MessageObserver represent domain services that enforce application-specific business logic. The benefits of separating these domain services can become more evident by introducing a coordinating component. Right now, the MessageFetcher and MessageObserver are not logically coupled in any meaningful way. In other words, invoking fetchMessages on the MessageFetcher will not have any effect on the MessageObserver.
We'll need to have PresentationLogic and MessageLoadingInfra rely on a component that implements both the trigger and observer.
Putting It Into Practice: Take Two
Let's introduce a component to federate the interactions between these domain services: we need some application-level glue to achieve the desired behavior.
class PaginationBinding:
FetchMessagesTrigger, NewMessagesObserver {
private var completion: (([Message]) -> Void)?
init() { ... }
...
func loadMessages() {
service.fetch { [weak self] messages in
guard let self = self else { return }
self.completion?(messages)
}
}
func bind(completion: @escaping ([Message]) -> Void)) {
self.completion = completion
}
}
Now, we can have two sub-components, each relying on the FetchMessagesTrigger and NewMessagesObserver, respectively, and still achieve coordinated behavior between the components.
Let's revise how these components will now be composed.
class PaginationBinding:
FetchMessagesTrigger, NewMessagesObserver {}
class PresentationLogic {
init(trigger: FetchMessagesTrigger { ... }
...
}
class MessageLoadingInfra {
init(observer: NewMessagesObserver) { ... }
...
}
let binding = PaginationBinding()
let presentationLogic = PresentationLogic(trigger: binding)
let loadingInfra = MessageLoadingInfra(observer: binding)
Now, binding to the PaginationBinding will result in updates whenever loadMessages is called. By separating triggering an action and binding to an action into separate protocols, we allow lower-level modules to depend on behavior, rather than concern themselves with the coordination of behavior.
That's all for now; be well.
Nick