Model controllers in Swift

⚠️ This page is for testing my new syntax highlighter. You can find the permanent "Model controllers in Swift" article here.

Proper encapsulation of logic is one of the most important things when it comes to building well-architected apps and systems. By limiting access to a given value or object to those that really need it, we can create more well-defined relationships and reduce the amount of code paths that we need to test.

While we've explored several different code encapsulation techniques before on this blog, this week - let's take a look at how we can improve encapsulation specifically in our model layer, using model controllers.

Shared model logic

Most apps contain many different kinds of models, but in general they can be separated into two categories - shared and local. Local models are the ones that are only used in a narrow part of our app - for example a Bookmark in a book reading app - which may only be used in some form of Bookmarks view and when actually reading a book.

Shared models, on the other hand, are the ones that are used in many different parts of our app. While it might be argued that shared models kind of goes against the whole idea of encapsulation to begin with - it's really hard to design an architecture that doesn't include any kind of shared information - most apps have some central piece of data that the whole app is built around.

A very common example of such a shared model is a User model - which we might use to keep track of the currently logged in user and the data associated with that account, looking something like this:

struct User: Codable {
    var firstName: String
    var lastName: String
    var age: Int
    var groups: Set
    var permissions: Set
}

With shared models often comes shared logic - we often need to perform similar checks against certain properties in many different parts of our app. For example, if we're building some form of social networking app, we might need to check if the current user can comment on a given post in many different places. Rather than duplicating that logic, we'll extend User to itself contain it:

extension User {
    func canComment(on post: Post) -> Bool {
        guard groups.contains(post.group) else {
            return false
        }

        return permissions.contains(.comments)
    }
}

The above works - and is a very common approach - but if we think about it from an architectural point of view, it's a bit strange that we're adding logic and decision-making to a model. One thing that most design patterns agree on - including ones like MVC, MVVM and VIPER - is that models should ideally be simple data containers that don't contain much (if any) logic.

While most design patterns have an answer as to where to put this kind of logic for local models, for shared models it's a bit tricker, since most iOS design patterns tend to be very focused around various screens and parts of the UI - both a ViewModel from MVVM and a Presenter or Interactor from VIPER are strongly coupled with the UI they are associated with (for good reasons).

Because of this, it's very common to end up putting logic like the above in some form of singleton or global function, which often hurts our code encapsulation and might become the source of shared mutable state and tricky bugs. Instead, let's take a look at how we can go back to the essentials of MVC, and make this kind of shared model logic part of our controller layer.

Model controllers

A common misconception about the iOS flavor of MVC is that all controllers need to be view controllers. However, like we took a look at in "Logic controllers in Swift" - it can sometimes be a great solution to split up the controller layer into multiple, dedicated controllers - not all of which need to be controlling a view.

Another implementation of such view-less controllers is model controllers. Just like how a view controller can be described as the "brains" behind a UIView, a model controller performs all the logic associated with a single instance of a model - allowing us to properly encapsulate model-specific logic.

This is not a new concept - Apple have been using model controllers for ages - just take a look at NSArrayController or its base class; NSObjectController.

Let's build a model controller for our User model from before. We'll start by simply creating a class that can be initialized with a User instance, like this:

class UserModelController {
    private var user: User

    init(user: User) {
        self.user = user
    }
}

We could, of course, have simply called our model controller UserController - but just like with logic controllers, I personally prefer to spell out Model explicitly, since it creates a more clear separation from view controllers.

Next, let's move our logic for checking whether a user is allowed to comment on a given post to our new model controller:

extension UserModelController {
    func allowComments(on post: Post) -> Bool {
        guard user.groups.contains(post.group) else {
            return false
        }

        return user.permissions.contains(.comments)
    }
}

So far so good! 👍 But why does our model controller keep its User model private? How will other objects be able to read or write its properties?

Like we took a look at in "Code encapsulation in Swift", one of the big benefits of proper code encapsulation and clear API design, is that it prevents APIs from being used "the wrong way". If we exposed the underlying User to the outside world, we couldn't really guarantee that the right decisions will be made - as other parts of our code base might start reading properties directly and make their own decisions.

Instead, let's extend UserModelController with clear APIs for everything that we need from User. For example, we might need to compose a displayName for a profile view, or loop through all of the user's permissions in order to display them in a list - so lets add APIs for those use cases while still keeping the underlying model private:

extension UserModelController {
    typealias PermissionsClosure = (Permission, Permission.Status) -> Void

    var displayName: String {
        return "\(user.firstName) \(user.lastName)"
    }

    func enumeratePermissions(using closure: PermissionsClosure) {
        for permission in Permission.allCases {
            let isGranted = user.permissions.contains(permission)
            closure(permission, isGranted ? .granted : .denied)
        }
    }
}

We now have single, dedicated code paths for the above logic, which we can make sure to fully unit test - removing the risk of duplicated logic and inconsistencies, and reducing the risk of bugs.

Taking action

Model controllers can also be a great place to perform model-specific actions, such as updating local data from the server. Rather than having each view controller be responsible for making sure that they're always updating their copy of a User model, we can have a central place for that kind of logic - without introducing the need for singletons or breaking any architectural rules.

To add support for updates, let's add an update method to our UserModelController, which other parts of our code base can call in order to request an update. We'll also let any callers of this method attach a completion handler when calling it, which can be really useful when implementing things like pull-to-refresh. Here's what our UserModelController now looks like:

class UserModelController {
    private var user: User
    private let dataLoader: DataLoader

    init(user: User, dataLoader: DataLoader) {
        self.user = user
        self.dataLoader = dataLoader
    }

    func update(then handler: @escaping (Outcome) -> Void) {
        let url = Endpoint.user.url

        dataLoader.loadData(from: url) { [weak self] result in
            do {
                switch result {
                case .success(let data):
                    let decoder = JSONDecoder()
                    self?.user = try decoder.decode(User.self, from: data)
                    handler(.success)
                case .failure(let error):
                    handler(.failure(error))
                }
            } catch {
                handler(.failure(error))
            }
        }
    }
}

Above we can see yet another benefit of our model controller being a controller - not a model - we can inject dependencies into it and have it perform tasks such as networking, and still maintain an MVC-aligned architecture.

Observing changes

Since we're now able to update our model, we'll also need some way of observing when it changes. Thankfully, since we opted for a tightly encapsulated design (by not exposing our underlying User model), adding observation support in a safe way should be relatively easy.

Like we took a look at in the two-part article "Observers in Swift", there are many different ways we can enable an object to be observed. In this case, let's use a simple observation protocol, since we only have a single event that needs to be observed - when our UserModelController was updated:

protocol UserModelControllerObserver: AnyObject {
    func userModelControllerDidUpdate(_ controller: UserModelController)
}

extension UserModelController {
    func addObserver(_ observer: UserModelControllerObserver) {
        // See "Observers in Swift" for a full implementation
        ...
    }
}

Then, when we update our private User value, we simply notify all observers (by, for example, iterating over them and calling the above observation method from our UserModelControllerObserver protocol):

class UserModelController {
    private var user: User { didSet { notifyObservers() } }
}

Finally, let's take a look at how everything comes together at the call site. For view controllers that want to use user data in any way, we now inject our UserModelController instead of a User value - and use the APIs we defined above to render the data our model controller gives us, like this:

class HomeViewController: UIViewController {
    private let userController: UserModelController
    private lazy var nameLabel = UILabel()

    init(userController: UserModelController) {
        self.userController = userController
        super.init(nibName: nil, bundle: nil)
        userController.addObserver(self)
    }

    override func viewDidLoad() {
        super.viewDidLoad()
        render()
    }

    private func render() {
        nameLabel.text = userController.displayName
    }
}

extension HomeViewController: UserModelControllerObserver {
    func userModelControllerDidUpdate(_ controller: UserModelController) {
        render()
    }
}

We can now do the same thing for all other view controllers that rely on user data, enabling us to use the exact same model logic for all of them 👍.

Conclusion

Code encapsulation is in many ways all about giving each type a very distinct and clearly defined area of responsibility, and to not leak any implementation details to the outside world. Model controllers can be a great tool to achieve just that, for shared models that need a fair amount of logic associated with them in order to work.

There are of course many other ways to approach the same problem in Swift (that's one of the things I love most about programming). Another way that we've explored previously (in "Handling mutable models in Swift") is by using Handlers, which can be a great option for turning a value type into an observable reference type - when not that much additional logic is required. Not everything should be a controller, but when an object is actually responsible for controlling a single entity - it often makes sense.

What do you think? Have you used model controllers before, or is it something you'll try out? Let me know - along with your questions, comments or feedback - on Twitter, Mastodon or Micro.blog.

Thanks for reading! 🚀