How to modularize Monolith iOS App

Featured on Hashnode
How to modularize Monolith iOS App

If you prefer to watch video you can find it here.
Hey iOS folks!

If your application has grown in complexity over time, modularization can be a good strategy to improve development efficiency, build time, scalability, maintainability, and etc. Today, we will precisely examine the path from monolith to modular app, step by step, and provide insights about each step along the way.

From monolith to modularization

Before transitioning to modularity, your app likely adhered to some monolithic architecture that followed some form of layered architecture, one of the most common and widely used software architecture patterns. Layered architecture organizes the components of an application into separate horizontal layers, with each layer serving a specific function or responsibility.

As a typical example for possible layers:

  • Data/Persistence layer: responsible for data storage and retrieval.

  • Business Logic layer: contains the application's core functionality and logic.

  • Presentation layer (UI): handles user interface and communication.

NOTE: For example, CLEAN, Onion, Service Oriented (SOA) architectures are examples of layered architecture.

In addition to layer separation, we also need to keep in mind the dependency direction rule:

Layers can depend UPWARD but NOT DOWNWARD.

The dependency direction rule heavily relies on the Dependency Inversion principle, enabling us to maintain loose coupling between our layers, providing encapsulation and scalability.

Layered Modular Architecture

The concept of layered architecture is straightforward and can actually be applied to our modular architecture as well.

In essence, modularization involves dividing your app into separate modules. Thus, you can apply layered approach to modularization by integrating your modules into specific layers.

We'll structure our modularized app into three layers: Core, Features, and Composition Root.

Core Layer:

  • Contains code that is used across various features of the app.

  • Offers shared functionality that is agnostic to the specific app, encompassing UI components, networking, analytics, utilities, metrics, authentication, logging, and more.

  • Core modules operate independently of each other; they do not depend on other core modules.

Feature Layer:

  • Feature modules encapsulate distinct features or functionalities of the app, such as specific screens, sets of related screens, or views - examples include payment processing, customer profiles, restaurant list, search, support chat and etc.

  • They contain all code relevant to specific feature, covering UI, logic, and data handling.

  • While feature modules may depend on core modules if necessary, they should not depend on other feature modules.

  • Feature modules promote separation of concerns, facilitating independent development, testing, and reusability across projects.

Composition Root:

  • The composition root serves as a central place within the application where the object graph is assembled/composed. Its primary responsibility lies in creating and injecting all the dependencies throughout the application.

  • Composition Root pattern enables the modules (and layers) to maintain loose coupling, facilitating seamless transitions between implementations if needed.

  • It's important to note that the composition root itself is not a module; rather, it resides within your app's main target.

  • Each feature will have its own designated composition folder within the composition root, referred to as the Feature[X]Support folder (or Feature[X]Composition if you like).

  • For those unfamiliar with Dependency Injection (DI) and the concept of Composition root, further details can be found here, here and here.

NOTE 1: The same dependency direction rule applies to modular layers, but we also need to enforce the restriction that modules within one layer should not depend on each other. Together with dependency direction rule this allows to avoid circular dependencies. We achieve this by applying Dependency Inversion principle and abstracting dependencies from other modules using public protocols. (This rule is applicable to both core and feature layers.)

NOTE 2: We haven't forbidden feature modules from depending on core modules. This allowance is important, particularly in scenarios involving shared UI components, where direct dependencies can sometimes be preferable to avoid the added complexity of abstraction through dependency inversion. However, it's important to note that not all core packages need to be direct dependencies of features; in fact, the fewer direct dependencies your feature module has, the more flexibility it gets. Inverting the dependency on core modules fosters loose coupling between core and feature layers, making it easier to substitute/reuse modules in the future. It's beneficial to abstract core module dependencies and inject them via the composition root, but abstraction goes with related cost (will discuss it a bit later with example).

NOTE 3: We haven't specified the method of implementing modularity—whether it's through SPM, CocoaPods, or any other means—because, in reality, it doesn't matter for the modularization strategy we're discussing here. You're free to choose whichever method you prefer.

Getting started

So, now you're ready to begin migrating from a monolithic to a modular structure.

Logically, the first step is to migrate the core modules. How do you determine which code should be moved to the core layer? Look for functionality that is shared and required by multiple feature modules in your application. This may include UI components, networking, authentication, analytics, logging, utility functions, and so on.

Once all core modules are modularized, let's consider a scenario where you have a feature within your monolith that you want to modularize next. You need to create a new module and move the related code there.

Now, it's important to ensure that the dependency direction rule is not violated.

You may find yourself in one of the three situations listed below:

1) Your module depends on another feature (SAME LAYER)

It’s not allowed by our dependency direction rule.

We need to do Dependency Inversion to comply with it.

This means that our module should depend on abstractions/interfaces/protocols, rather than concrete implementations that lies in another feature module.

Let's clarify how we can do it with a typical example. Imagine that we have a navigation scenario where one feature presents another.

// somewhere inside your feature module...
import AnotherFeature // Can't depend on AnotherFeature module here
import UIKit

final class YourFeatureViewController: UIViewController {
    private func showAnotherFeature() {
        let vc = AnotherFeatureViewController()
        present(vc, animated: true)
    }    
}

To get rid of dependency on AnotherFeature, we want to invert this dependency. We create the public routing protocol inside of our new feature module:

public protocol YourFeatureRouting {
    func showAnotherFeature()
}

Now we can use this protocol for navigation:

import UIKit

final class YourFeatureViewController: UIViewController {
    private let featureRouter: YourFeatureRouting

    init(featureRouter: YourFeatureRouting) {
        self.featureRouter = featureRouter
    }

    private func showAnotherFeature() {
        featureRouter.showAnotherFeature()
    }    
}

For example, in this case, your feature interface is a factory that creates view controller for specific screen. We need to add routing protocol as a required dependency of our feature by putting it into factory params list.

import UIKit

public enum YourFeatureFactory {
    static func make(
        featureRouter: YourFeatureRouting
    ) -> UIViewController {
        YourFeatureViewController(featureRouter: featureRouter)
    }
}

Now, our module explicitly declares that it requires the user of the module (the caller side, in our case, the composition root) to provide an implementation for the YourFeatureRouting protocol, which our feature is intended to use.

Next, inside our composition root in YourFeatureSupport folder we will implement the router and conform to this routing protocol:

import AnotherFeature
import UIKit
import YourFeature

struct YourFeatureRouter: YourFeatureRouting {
    var source: UIViewController?

    func showAnotherFeature() {
        let vc = AnotherFeatureViewController()
        source?.present(vc, animated: true)
    }
}

Now we need to compose everything together with our router using composition factory:

import UIKit
import YourFeature

enum YourFeatureCompositionFactory {
    static func make() -> UIViewController {
        let featureRouter = YourFeatureRouter()
        let featureService = YourServiceFactory.make()
        let vc = YourFeatureFactory.make(
            featureRouter: featureRouter,
            homeService: homeService
        )
        featureRouter.source = vc
        return vc
    }
}

And that’s it! We inverted the dependency on AnotherFeature.

2) Your module depends DOWNWARDS

It’s not allowed by our dependency direction rule.

Decide the optimal location for the code you depend on:

  • If it’s your new feature module:

    • Move or copy the dependent code to your new feature module.

or

  • if it’s another module (whether feature or core):

    • Transfer it to another module (you can create a new module if required).

    • Invert the dependency using the same approach. Create a public protocol and specify what your new feature module expects to receive from the composition root. Inject the dependency through the feature interface.

3) Your module depends UPWARDS

This aligns with our dependency direction rule.

As previously mentioned, depending on the core module from feature module can be essential like for the UI components core module (CompanyUIKit). For some core modules you need to consider whether to proceed with this direct dependency or to invert the dependency by injecting it from the composition root, as we have done previously.

Dependency inversion fosters loose coupling between core and feature layers, provides more flexibility and reusability.

For example, imagine that you depend on Networking core module from your feature module:

  • (Flexibility) Imagine that you wish to replace the obsolete Networking module with the brand new NetworkingGPT module.

    • If you directly depend on the networking module, you'll need to modify all networking-related code within the feature module and update imports in every file where you use code from networking module.

    • Conversely, if you've inverted the dependency and injected it via the composition root, it becomes easier to make the switch. By defining a public feature service protocol to be implemented within the composition root, you conceal the networking implementation details from the feature module. All that's required is to provide a new implementation for the feature service protocol within the composition root.

  • (Reusability) If you aim to reuse your feature module in another project, it becomes simpler to move it.

  • (Cost of benefits) However, this approach introduces a bit more complexity and necessitates writing additional code.

Consider these pros and cons in the context of your specific scenario.

Final thoughts

The idea I wanted to convey here is that modularization isn't rocket science. It's not quick either, especially for larger projects. However, the positive aspect is that you can undertake it gradually. Rome wasn't built in a day, and with patience, you can succeed as well.

You can find a simple modular project example here. See you in the next post!

Did you find this article valuable?

Support Vitaly Batrakov by becoming a sponsor. Any amount is appreciated!