Code encapsulation in Swift

One of the biggest challenges when working on a continuously evolving code base is to keep things nicely encapsulated. As new features are added, objects often get new responsibilities, or need to work with other objects in ways they weren't originally designed for. Adding this kind of new capabilities without leaking abstractions can be really tricky.

In many ways, this comes down to API design. Clearly defining APIs for our types can really help us encapsulate our code and avoid sharing unnecessary implementation details with other types. This week, let's take a look at a few techniques that can let us do that in different situations.

Hiding implementation details

So exactly why is hiding implementation details from other types so important? Let's take a look at an example, in which we are building a ProfileViewController that we use to display the currently logged in user's profile. It has a header view that it becomes the delegate of in viewDidLoad, like this:

class ProfileViewController: UIViewController, ProfileHeaderViewDelegate {
    lazy var headerView = ProfileHeaderView()

    override func viewDidLoad() {
        super.viewDidLoad()
        headerView.delegate = self
        view.addSubview(headerView)
    }
}

The above may look really straight forward, but we actually end up exposing an implementation detail. Since the headerView property isn't private (or fileprivate), it should be considered part of the API of ProfileViewController. While this may seem like a nitpicky detail, it can actually increase the risk of introducing bugs.

Let's say we're introducing a new feature in our app that lets our users unlock a premium mode by performing an in-app purchase. When the user does that we want to customize the profile header view to look a bit more fancy (as a little thank you), so we end up with something like this:

func userDidUnlockPremiumSubscription() {
    profileViewController.headerView = PremiumHeaderView()
}

The problem is that by running the above function, the delegate relationship between our profile view controller and its header view is lost (since we are completely replacing the instance). That'll most likely lead to bugs and an unresponsive UI, and since it's being triggered by an external condition that we might not always test for (in this case an in-app purchase) - the risk is that these bugs go undiscovered for a while.

To hide this implementation detail and prevent this kind of bugs from happening, let's instead make the headerView property private:

class ProfileViewController: UIViewController, ProfileHeaderViewDelegate {
    private lazy var headerView = ProfileHeaderView()
}

That's a great first step, but we also need to provide some form of API that allows other types to customize ProfileViewController for the premium mode we just introduced.

What we'll do is that instead of exposing the header view itself, we'll simply add a dedicated API that lets us customize what mode the profile view controller should currently be in, like this:

extension ProfileViewController {
    enum Mode {
        case standard
        case premium
    }

    func enterMode(_ mode: Mode) {
        switch mode {
        case .standard:
            headerView.applyStandardAppearance()
        case .premium:
            headerView.applyPremiumAppearance()  
        }
    }
}

Worth noting above is that we don't expose the Mode type down to the header view. Instead, we implement separate methods to apply separate appearances to it (which in turn will tweak things like colors, images, etc). That way we don't create a strong coupling between the view controller and its header view.

The code that we run in response to an in-app purchase can now look like this:

func userDidUnlockPremiumSubscription() {
    profileViewController.enterMode(.premium)
}

By performing these changes, we have now encapsulated ProfileHeaderView (which reduces the risk of it being used "the wrong way") and we've added an explicit API that we can more easily maintain and extend as our app keeps evolving 👍.

Protocols and private types

Another great way to encapsulate code is to use protocols in combination with private implementations. Like how we used protocols to only give certain types access to things like writing to a database in "Separation of concerns using protocols in Swift", let's take a look at how protocols can be used to present a simple API to the outside world, while hiding complexity under the hood.

Let's say we're building an app that needs to load a lot of images in its various view controllers. To kick things off, we create a protocol that defines the API for an ImageLoader that each of our view controllers can use:

protocol ImageLoader {
    typealias Handler = (Result<UIImage>) -> Void

    func loadImage(from url: URL, then handler: Handler)
}

Since we're going to have many different view controllers that all need to load images, we want each of them to use their own image loader instance. This will allow us to perform optimizations like cancelling outstanding requests when a view controller gets deallocated.

To deal with this in a nice way, let's use the factory pattern and build an ImageLoaderFactory that we can use to create separate instances for each view controller. The trick here is that we won't reveal what concrete type of image loader that the factory returns - instead it just returns any type conforming to ImageLoader, while creating an instance of SessionImageLoader under the hood, like this:

class ImageLoaderFactory {
    private let session: URLSession

    init(session: URLSession = .shared) {
        self.session = session
    }

    func makeImageLoader() -> ImageLoader {
        return SessionImageLoader(session: session)
    }
}

Since we don't include any concrete type in our API, we are free to keep the entire implementation of SessionImageLoader private:

private extension ImageLoaderFactory {
    class SessionImageLoader: ImageLoader {
        let session: URLSession
        private var ongoingRequests = Set<Request>()

        init(session: URLSession) {
            self.session = session
        }

        deinit {
            cancelAllRequests()
        }

        func loadImage(from url: URL,
                       then handler: (Result<UIImage>) -> Void) {
            let request = Request(url: url, handler: handler)
            perform(request)
        }
    }
}

The advantage of doing something like the above is that it gives us a lot of flexibility, while also keeping the API really simple. All our app knows is that it's able to load images - we remove the risk of starting to rely too much on the URLSession-based implementation throughout our code base, and we can even do things like replace SessionImageLoader with a mock during development (which is very useful if we don't yet have a server up and running) without changing any other part of the app.

Third party dependencies

The same protocol-based approach can also be super useful in order to encapsulate third party dependencies. One danger of using things like open source frameworks and third party SDKs is that they can easily start spreading across an entire code base, potentially making it really hard to do things like upgrade to a new major version or to switch to an alternative solution.

Just like how we above used a protocol to hide our own SessionImageLoader class from the rest of our app, we could do the same thing if we were to use a third party framework for image loading. Here's an example of what our ImageLoaderFactory could look like if we used an imaginary framework called AmazingImages:

import AmazingImages

class ImageLoaderFactory {
    func makeImageLoader() -> ImageLoader {
        return AmazingImageLoader()
    }
}

Since we now only have a single file that actually interacts with the AmazingImages framework, we have a much more flexible setup, and we're not letting our code base get "locked in" to one specific version of one specific framework.

One interesting thing to audit from time to time, is to check how many files in a code base that import each framework that is being used. If that number is large for any dependency, it's usually a pretty clear signal that some form of additional encapsulation is in order.

Conclusion

Continuously working on encapsulating code as much as possible - even as new features are added, new dependencies are introduced, or requirements change, is super important in order to be able to maintain a nice architecture, a healthy degree of flexibility and clear relationships between objects.

That's not to say that all code should always be super tightly encapsulated (that can in fact also lead to very inflexible and over-complicated solutions). Like with most things, balance is key, and regularly using refactoring is a great way to keep things nice & tidy.

What do you think? How do you usually ensure that your types are nicely encapsulated - do you use some of these techniques or some other one? Let me know, along with any questions, comments or feedback that you might have on Twitter @johnsundell.

Thanks for reading! 🚀

The power of sets in Swift

Navigation in Swift