When the Coordinator fails for you, hire an Assistant

Update. This approach is no longer valid, an Assistant is basically a Model, as what I wrongly described here was an MVC without the M part.

I’ve been lately using the the Coordinator pattern (actually its variation Coordinator+ViewModel), and while the pattern is a very good one in terms of separation of concerns, it does have some limitations that you might run at a certain point.

The good parts of the Coordinator pattern:
– Orchestrates the navigation flow, decoupling this logic from the Controller
– Because the Coordinator handles mainly the navigation logic it’s easier to grasp the actual flow within the app
– It’s a clean way to build up UI hierarchies, starting from the root level, meaning less code in AppDelegate
– Adding a ViewModel along means way less business logic in the Controller (yay!)

Where it fails:
– Reusability: I found it hard to reuse a controller with a dedicated coordinator, as would have to carry the coordinator along, or parts of the coordinator. This couples a controller as part of a single flow
– Sometimes awkward: should we also delegate alert presentation to the Coordinator? Should we add delegate method to support all kind of alerts we want to display?
– State restoration, which iOS gives it to us almost for free, is hard to achieve with Coordinator, as the iOS SDK expects the Controller to be the main actor, thus iOS will restore only the hierarchy of controllers
– Can’t be used efficiently with Storyboards

It’s variation Coordinator+ViewModel brings another concern: from an architectural point of view it feels unnatural to delegate UI actions to the ViewModel, which will simply forward them to the Coordinator; this is at least a matter of mixed responsibilities (even if the ViewModel simply forwards the calls upstream).

Another concern is that once we built up a Coordinator hierarchy, we need to intercept all kind of scenarios, like ones where a Controller is dismissed due to extrinsic causes, to avoid situations when we end up with “zombie” Coordinators.

The thing is, we like it or not, the iOS platform is built around MVC. Many features revolve around the concept of controllers: we build navigation controllers, push other controllers to them, we ask controllers to dismiss themselves, we configure navigation items for them, etc.

We do however need to address the Big Fat Controller concern, as we simply cannot put all our business logic in the Controller just because the MVC pattern doesn’t provide us with a better place for it. We need to delegate this work to another object (like Coordinator and ViewModel did), however we do not need to loose control.

I call this the Assistant object. Basically controllers will either create an assistant for their own, or will receive it from the upstream controller, depending on the situation.

This is a simple way to keep the Controller as the main actor, but still leverage the amount of responsibilities the Controllers needs to handle.

This opens up the way to new possibilities, like injecting the same assistant to the downstream controller. We can use protocols to expose only what we want to expose downstream. Having both controller work with the same assistant means that we no longer need delegates in order for the downstream controller to notify upstream that its job is done and the collected data needs to be recorded, as the data is already in the correct place; we only need to reconcile the UI with the assistant data, and we can do this automatically by being a delegate for the assistant.

Here’s a simplistic example of this pattern in practice. Let’s assume we have a list of persons we want to manage by adding/removing persons from a list:


class PersonsAssistant {
    var persons: [Person] = [] {
        didSet { onPersonsChange?() }
    }
    
    /// Using callbacks instead of delegate as it's more convenient
    var onPersonsChange: (() -> Void)?
    
    func addPerson(_ person: Person) {
        persons.append(person)
    }
    
    func removePerson(at index: Int) {
        persons.remove(at: index)
    }
}

public class PersonsListViewController: UITableViewController {
 
    var assistant: PersonsAssistant
    
    init(assistant: PersonsAssistant) {
        self.assistant = assistant
        super.init(style: .plain)
        navigationItem.title = "Acquaintances"
        navigationItem.rightBarButtonItem =
            UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(onAdd))        
    }

    func viewDidLoad() {
        super.viewDidLoad()
        assistant.onPersonsChange = { self.tableView.reloadData() }
    }
}

extension PersonsViewController {
    @objc func onAdd(_ sender: Any) {
        let addPersonVC = AddPersonViewController(assistant: assistant)
        navigationController?.pushViewController(addPersonVC, animated: true)
    }
}

We’re letting the assistant handle the data operations, while the controller only watches for changes in the data and reloads it’s UI when that happens (I’ve used callbacks here as a data change communication mechanism, but nothing stops you in using delegates, RxSwift/ReactiveSwift, or other techniques).

This is how would the AddPersonViewController might look like:

class AddPersonViewController: UIViewController {
    let assistant: AddPersonAssistant
    
    public init(assistant: AddPersonAssistant) {
        self.assistant = assistant
        super.init(nibName: nil, bundle: nil)
        navigationItem.rightBarButtonItem =
            UIBarButtonItem(barButtonSystemItem: .save, target: self, action: #selector(onSave))
    }
}

extension AddPersonViewController {
    @objc func onSave(_ sender: Any) {
        let person = Person(firstName: "John", lastName: "Doe")
        assistant.addPerson(person)
        navigationController?.popViewController(animated: true)
    }
}

We achieved a couple of things here:
1. The Controller is in the driving seat, at least regarding the UI, and that’s a good thing, as this is it’s meaning
2. We fully decouple the business logic from the controller, we now have a dedicated object that does this for us. This object is very similar to a ViewModel, however I wanted to avoid this term as IMO it doesn’t make so much sense outside of MVVM
3. Interface Segregation (the I from SOLID) – AddPersonViewController knows nothing more about the assistant than it needs to know in order do its job
4. Increased testability – since the Assistant knows nothing about UI components, it should not be hard to obtain high testing coverage over that unit

One downside is that we loose the manager of the initial controller, for which the Coordinator pattern fits perfectly. Now we’re back to either using AppDelegate for this (not a good idea at all, as the AppDelegate might be already burdened with lots of other responsibilities), or delegate this to a RootControllerConfigurator object (still seeking for a better name, open to suggestions here :).

To me, it feels that the Assistant approach fits more naturally within the iOS ecosystem. Haven’t use it much in practice yet, but I plan to 🙂

Leave a comment

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