Modular Architecture for iOS in Swift

September 23, 2015
CC liscense

Photo under CC by Pascal

I spoke at AltConf 2015 about the Modular Architecture we at Coursera use to write our iOS App. This architecture strives to encapsulate and bound our features to reduce complexity and improve reliability. Some great advantages of this approach are:

How does it work?

In our architecture this promotion of modularity occurs at two levels: the application space and the feature space. You’ll see in the full Github example that we use a modified version of VIPER internal to features, but I’m not going to talk about that here. Today I want to walk through how to modularize features in the application space.

Features as Modules

To start off we are going to consider every feature added to our application encapsulated within a module as a dynamic framework. This allows us to add to our application using these frameworks as building blocks.

features

Among many benefits, isolating code into features (modules) creates a scalable architecture that retains reliability over time.

However, its impossible to keep theses modules as isolated as depicted above. Consider the example of a ‘Home Screen’ module that is the landing page of an app and a ‘Login’ module that allows the user to log into the application. Its common that when a user tries to access some restricted part of a ‘Home’ feature a ‘Login’ page is launched. Likewise, the ‘Home’ feature probably wants to know whether the login action succeeded or not in order to show a ‘guest’ or logged in user experience.

login

Example of Login module wanting to present the Home feature and the Home wanting to present Login and get the result of the login action.

To enable module communication while maintaining loose coupling a module router is utilized to that accepts URL requests and routes requests between modules. This could mean asking our payment module to process a transaction or using the router to present the login module as in the example above.

router

Example of how the router works to communicate between modules.

This module router lives in a foundational framework that is shared across our application. Since this foundation is shared between every module we need to be very careful to limit what we put in it to avoid the coupling we are trying to prevent.

A Routing Example in Code

Since our Module Router is going to be universally accessible within the application we are going to make it a public singleton within our foundational framework.

public  var SharedModuleRouter: ModuleRouter {
    struct Singleton {
        static let instance = InternalModuleRouter()
    }
    return Singleton.instance
}

Now that modules can access the module router they will utilize three parts of it to perform inter-feature communication. To demonstrate these I’ll walk through its protocol:

1. Registering feature modules for the URLs they can consume:

public protocol ModuleRouter {
    // Register consumers for URLs
    func registerModuleClass(consumer: AnyClass, URLPath: String)
     …

2. Obtaining the common public interface for a module registered for a ModuleURL:

     ...
    func moduleForURLPath(URLPath: String) -> ModuleViewController?
     ...

3. Utilizing a HostApp object to perform the actual routing in the application level space:

    func registerHostApp(hostApp: ModuleURLObserver)
    func hostApp() -> ModuleURLObserver?
}

I’m not going to talk about how the HostApp object works here, but you can check out that aspect in the full example. Before we move onto discussing the module-level interface I’ll mention that we define our ModuleURLs through an enumeration and use a property on that URL to provide the path string:

public enum ModuleURL: Printable {
    case LoginModuleURL
}

extension ModuleURL: Path {
    public var path: String {
        switch self {
        case .LoginModuleURL: return “/login"
        }
    }
}

The Module Interface

The only public object within a module framework is a module UIViewController used a its public interface. A UIViewController was chosen for this interface because transitioning between modules often requires modifying the view stack and using a UIViewController also leverages the OS’s parent-child relationship structure.

Conformance to the ModuleURLObserver protocol is required for the HostApp and ModuleViewControllers to handle routing requests.

public protocol ModuleURLObserver: class {
    func handleModuleURL(URLString: ModuleURL, successCallback: ModuleURL?, failureCallback: ModuleURL?) -> Bool
}

We strictly refrain from adding any other methods to the ModuleViewControllers aside from this conformance to maintain uniformity and prevent the desire to add convience methods that couple features together.

Let’s Get Routing

Modules can issue requests to launch another feature through our shared router either directly or by using the host app:

// Directly issuing a request
let loginURL = ModuleURL.LoginModuleURL
if let loginViewController =  ModuleRouter.viewControllerForURLPath(homeURL.path) {
    loginViewController.handleModuleURL(homeURL)
    homeViewController.navController?.pushViewController(loginViewController, animated: true)
}
// Using the host app
SharedModuleRouter.hostApp().handleModuleURL(ModuleURL.HomeModuleURL)

Modules handle these ModuleURLs by modifying their feature state to perform the expected behavior. For example, presenting the login page and kicking off any necessary network calls.

 
    public override func handleModuleURL(URLString: ModuleURL, successCallback: ModuleURL? = nil, failureCallback: ModuleURL? = nil) -> Bool {
        switch URLString {
        case .Login:
            // present login page         
            return true
        default:
            println("Invalid URL \(URLString)")
            return false
        }
    }   

That’s it! Using this technique we can communicate between features while keeping them independent. For a more complex, working example of Modular Architecture take a look at the GitHub Example.