Events Driven Architecture in iOS with MERLin

If you are not new to iOS development, you know how important is to correctly decouple app components for testability and overall flexibility. Each feature should work independently from the others. Ideally, each feature should be easily replaceable with another component providing the same functionality. To achieve this result we embrace protocol oriented development and dependency injection. Most importantly, we formally structure our apps following well-known architectural design patterns. Let’s have a look at the events driven architecture for iOS with MERLin.

Events Driven Architecture

“Events driven architecture is a software architecture pattern promoting the production, detection, consumption of, and reaction to events

– Wikipedia

There are two main actors in this architectural design pattern: an events Producer and a Consumer of events (or Listener).

We define an event as a message sent by a system to notify listeners of a change of state. A producer of events is a component that is able to notify listeners of changes to its internal state. The only responsibility of the producer is to send these messages without the knowledge of the existence of a listener that will react to the emitted events.

A listener, on the other hand, is a system interested in events emitted by one or more producers. Its job is to listen to emitted events and react to them producing an effect that will fulfil the purpose of the listener. An example would be a products list analytics events listener in an eCommerce app. It would listen to events generated by a products list producer and log to an analytics provider the events of “product selected”, “refinement button tapped” and so on…

As previously mentioned it is possible to have many listeners for each producer instance. Each listener should have one unique responsibility (analytics, routing, internal logging and so on)

Benefits

Loosely coupled components: Having a producer broadcasting events ensures that the producing component does not have any knowledge of the receiver(s) of the message. Furthermore, neither the producer nor the receiver has knowledge of the other’s implementation details (usually masked by a protocol).

From this derives high modularity: As long as two producers produce the same set of events they can replace each other without affecting listeners implementation, or other producers.

Parallelising development: After agreeing on the set of events that a specific producer can emit, two different devs can parallelise the development of listeners and producer. Producers can be mocked to trigger listeners reactions while the real implementation is under construction. From the Producer point of view, it has no knowledge about the listeners that will subscribe to its events.

Testability: Producers can be tested independently from the rest of the app thanks to their own nature of isolated components. Producers can also be replaced by mocks emitting the same events expected by a specific listener, so listeners can be tested with mock producers.

Events Driven Architecture with MERLin

MERLinIn an events driven architecture, it is important to have a solid events structure and a solid event dispatch mechanism. In iOS, unfortunately, none of this is available out-of-the-box. The only out-of-the-box events dispatch mechanism available to devs is the NotificationCenter that lacks in type safety and events are basically strings.

That’s how MERLin comes into play. MERLin is a reactive framework that aims to simplify the adoption of an events driven architecture within an iOS app. It emphasises the concept of modularity, promoting an easy to implement communication channel to deliver events from producers to listeners.

MERLin uses RxSwift to establish a communication channel between producers and listeners.

MERLin core principles

Module

In MERLin a Module is a framework that exposes a specific functionality; an app feature.

  • It’s independent or it has very limited dependencies.
  • It should not know about other modules and does not expose implementation details.
  • Any module can be a producer of events of a specific type and it must be contextualised (it needs a context to be built).
  • Different modules providing the same functionality should ideally produce the same events.
extension UIStoryboard {
    static var restaurantsList: UIStoryboard {
        return UIStoryboard(name: "RestaurantsList", bundle: Bundle(for: RestaurantsListModule.self))
    }
}

public class RestaurantsListModule: NSObject, ModuleProtocol, EventsProducer, PageRepresenting {
    public var context: ModuleContext
    
    public var pageName: String = "Restaurants List"
    public var section: String = "Restaurants List"
    public var pageType: String = "List"
    
    public var events: Observable<RestaurantsListEvent> { return _events }
    private let _events = PublishSubject<RestaurantsListEvent>()
    
    public func unmanagedRootViewController() -> UIViewController {
        let controller = UIStoryboard.restaurantsList.instantiateInitialViewController()!
        guard let listController = controller as? RestaurantsListViewController else { return controller }
        listController.viewModel = RestaurantsListViewModel(events: _events)
        
        return listController
    }
    
    public required init(usingContext buildContext: ModuleContext) {
        context = buildContext
        super.init()
    }
}

In this example, the module provides a feature capable of showing a list of restaurants. It has to provide an unmanagedRootViewController that will be the view controller that’s going to be shown on screen when needed.

The module is creating all the stack needed RestaurantsListViewController to work properly. In this specific implementation, there is aRestaurantsListViewModel that will be able to emit events that happened on the view controller, using the _eventsPublishSubject passed in the initialiser. This is just one way of handling events emission. This can vary based on your implementation details.

Event

An Event is named and unique within the same system domain and can have payloads describing the change that happened in the domain.

Different systems offering the same service should emit the same events.

In MERLin, Modules can be events producers and emit events to signal a change of state, or that an action was started/finished.

In MERLin, events are enums conforming the protocolEventProtocol.

public enum ProductDetailPageEvent: EventProtocol {
    case productLoaded(FullProductProtocol)
    case didAddToCart(BagProductProtocol)
    case didAddProductToWaitlist(FullProductProtocol)
    case didShareProduct(MinimumProductProtocol)
    case didChangeSize(newSize: ProductSizeProtocol)
    case didChangeColor(newColor: ProductColorProtocol)
}

We are defining a list of events of type ProductDetailPageEvent. A module emitting these events uses this enum to publish all possible events it can send to listeners. MERLin provides an easy way to capture specific events from a stream of events.

class MyModule: NSObject, EventsProducer {
    ...
    public var events: Observable<ProductDetailPageEvent>
    ...
}

let producer = MyModule(usingContext: ...)

//you can use EventsProucer subscript
producer[event: ProductDetailPageEvent.didAddToCart] //didAddToCart has a BagProductProtocol in the payload
    //The payload of the event is automatically extracted and the capture will become a 
    //stream of the type of the payload. In this case Observable<BagProductProtocol>
    .subscribe(onNext: { product in
        ...
    }.disposed(by: producer.disposeBag)

//you can capture events directly from the observable of EventProtocol
producer.events.capture(event: ProductDetailPageEvent.didAddToCart)
    .subscribe(onNext: { product in
        ...
    }.disposed(by: producer.disposeBag)

This way to capture events is possible thanks to the knowledge we already have about enums explained in this article.

Router

Some events might cause routing to another module of the app. The Router is the object that makes the connection possible in terms of UI. You must build a Router object in your app and it has to conform to the Routerprotocol. Via protocol extensions, MERLin offers many functions needed for the compliance to Router. This makes the creation of a new Router very simple; all of the complexity is reduced to defining the app’s root view controller.

class SimpleRouter: Router {
    var viewControllersFactory: ViewControllersFactory?

    required init(withFactory factory: ViewControllersFactory) {
        viewControllersFactory = factory
    }
    
    var topViewController: UIViewController { return rootNavigationController }
    private lazy var rootNavigationController: UINavigationController = {
        let presentableStep = PresentableRoutingStep(withStep: .restaurantsList(), presentationMode: .none)
        return UINavigationController(rootViewController: viewControllersFactory!.viewController(for: presentableStep))
    }()
    
    func rootViewController(forLaunchOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> UIViewController? {
        return rootNavigationController
    }
    
    func handleShortcutItem(_ item: UIApplicationShortcutItem) { } // Not implemented
    func showLoadingView() { } // Not implemented
    func hideLoadingView() { } // Not implemented
}

Here the root view controller is a simple UINavigationBar with a restaurantList as root.

Listener

In MERLin an events consumer is called Events Listener.

An events listener reacts to a module’s events; It can listen to specific types of events, or to any event (from any producer). Some events listeners can cause routing within the app. In that case, we will call them Routing Events Listeners. These special listeners have a router to make routing to new modules possible.

Here three examples of events listeners:

//This events listener is interested in any event from any producer
//The purpose of this listener is to log the event with a print
class EventsLogger: AnyEventsListening {
    @discardableResult func registerToEvents(for producer: AnyEventsProducer) -> Bool {
        producer.anyEvent
            .map { String(describing: $0) }
            .subscribe(onNext: { print($0) })
            .disposed(by: producer.disposeBag)
        return true
    }
}

//This listener is interested in ProductArrayEvent events
//The purpose of this listener is to log to analytics the productSelected event
//productSelected event has a payload that's the product that was selected.
class ProductArrayAnalyticsEventsListener: EventsListening {
    @discardableResult func registerToEvents(for producer: AnyEventsProducer, events: Observable<ProductArrayEvent>) -> Bool {
        producer[event: ProductArrayEvent.productSelected]
            .subscribe(onNext: { [weak self] (product) in
                print("[Analytics] Product \(product.productName) with iD \(product.remoteId) was selected")
                self?.track(event: "product.selected", properties:["id": product.remoteId])
            }).disposed(by: producer.disposeBag)

        return true
    }

    func track(event: String, properties: [String: Any]? = nil) { ... }
}

//This listener is interested only in ProductArrayEvent
//The purpose of this listener is routing, so that if a productSelected event
//happens, product detail page must be pushed on screen.
class MainFlowRoutingEventsListener: EventsListening, Routing {
    let router: Router
    
    init(router: Router) {
        self.router = router
    }

    @discardableResult func registerToEvents(for producer: AnyEventsProducer, events: Observable<ProductArrayEvent>) -> Bool {
        producer[event: ProductArrayEvent.productSelected]
            .toRoutableObservable()
            .subscribe(onNext: { [weak self] (product) in
                let step: ModuleRoutingStep = .product(routingContext: .mainFlow, id: product.remoteId, product: product)
                let presentableStep = PresentableRoutingStep(withStep: step, presentationMode: .push(withCloseButton: false, onClose: nil))
                self?.router.route(to: presentableStep)
            }).disposed(by: producer.disposeBag)

        return true
    }
}

Each listener should have only one responsibility. There is no limit to the number of listeners that can be listening to a specific module event channel, so it is possible to have a specific listener for each analytics provider, for each module.

Putting everything together

There must be a certain point in time new modules are presented to existing events listeners. This happens in the ModuleManager.

MERLin offers a ready to use implementation of the module manager. All you need to do is: inside your app delegate create a ModuleManager, create your listeners and pass the array of listeners to the instance of the module manager.

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {    
    var window: UIWindow?
    
    var moduleManager: BaseModuleManager = BaseModuleManager()
    
    var router: SimpleRouter!
    lazy var eventsListeners: [AnyEventsListening] = {
        [
            ConsoleLogEventsListener(),
            RoutingEventsListener(withRouter: router)
        ]
    }()
    
    func application(_ application: UIApplication, willFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
        router = SimpleRouter(withFactory: moduleManager)
        moduleManager.addEventsListeners(eventsListeners)
        
        eventsListeners.forEach { $0.registerToEvents(for: self) }
        
        return true
    }
    
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        window = UIWindow()
        window?.rootViewController = router.rootViewController(forLaunchOptions: launchOptions)
        window?.makeKeyAndVisible()
        
        _events.onNext(.didFinishLaunching)
        
        return true
    }
}

A router needs the module manager to “transform” a routing step into a view controller, so in practice, once this little setup is done, you’ll never have to deal with the moduleManager directly. All you will have to do is to focus on writing Modules and Listeners.

Events to Routing flow

RoutingEventsListeners will use the router with a routing step. The router will ask the ModuleManager for a view controller for a specific step and the module manager will create the right Module, present the module to events listeners, extract the viewController from it, and return the view controller to the router. During this phase, if a listener is interested in the events of the new producer, then it will subscribe.

Modules will remain alive for as long as the ViewController is alive. When the ViewController is deallocated the module instance will be deallocated as well.

There is so much more to tell about this framework. If you are curious check it out on GitHub, read the documentation or wait for the next article where we will explore in details EventsListeners and will give hints on how to structure your project in a smart and scalable way taking advantage of routingContexts.

 

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.