Modular Architecture in iOS

Oleh Kudinov
OLX Engineering
Published in
12 min readDec 18, 2019

--

International Space Station has 16 modules (by Shutterstock)

Modular Architecture is very popular topic in software engineering. As monolith application grows it becomes less and less maintainable and there is the need to split it in separate modules. On backend it is microservices and in Web is micro frontends. In this article we show how it works in iOS.

In the previous article, we have seen how to create an App using Clean Architecture + MVVM. Here we show how to improve your project by decoupling your app into isolated modules(e.g. NetworkingService, TrackingService, ChatFeature, PaymentsFeature…). Module isolations can help teams to work with these modules rapidly and independently.

Monolith is not bad when we are using good architecture like Clean Architecture + MVVM. Still, the app can become too big to be a monolith. And there is a need to make faster builds, reusable modules as frameworks, and isolated modules so people can work easily in separate cross-functional teams.

Big companies usually have their apps separated into dozens of modules. This year we managed to split our multi-millions users’ monolith OLX app into 12 modules. And now we always create a new module for new big enough features.

We call it Modular Architecture and it is also applicable to other platforms(e.g. Android or cross-platform like ReactNative or Flutter). In this architecture the App is separated into totally isolated Features modules which depend on Shared and Core modules.

In this article, we show how to separate the monolith App into isolated modules: Networking Service and Movies Search Feature. Networking Service module is configured inside the App and injected into Movies Search Feature:

And at the end of the article we will see how it can be scaled to big size, where every module has its own Clean Architecture + MVVM, Domain and DIContainer:

Scaled Modular Architecture

Note: detail description of this graph is below in the section: How Modular Architecture Scale

Modular Architecture

Modular Programming is a software design technique that emphasizes separating the functionality of a program into independent, interchangeable modules, such that each contains everything necessary to execute only one aspect of the desired functionality.

We use here CocoaPods as a dependency manager to split the App in isolated modules. CocoaPods is a powerful dependency management system that makes the frameworks integration very easy and convenient. Other dependency managers which we could use are Swift Package Manager or Carthage.

We create a new feature module when the feature is large enough to be called or considered as a product on its OWN. And it can be developed in isolation by cross-functional team.

Every module can have its own Architecture. Each team decides which architecture fits the best for their module to develop(e.g. Clean Architecture +MVVM, Redux..) and Domain. It means that all Domain Entities of the module will be fetched from API inside this module and mapped here. Note: when we want to share some Domain Entities like User we create CommonDomain module.

Every isolated module feature will have its own Dependency Injection Container to have one entry point where we can see all dependencies and injections of the module.

Every module will have an example/demo project to develop it in isolation fastly without compiling the whole app.

Every module's Tests also will be running fast without the host app. They will be with the module source code.

Important to mention that the modules are local, which means that they are located in the same repo(Monorepo) as the main App, inside the folder with the name DevPods. We only make them remote, in a separate repo, when we want to share them with another project. This makes sense until a new second project needs to use this module then it can be very easily moved to its own repo.

As a general rule, we try to minimize the use of 3rd party framework in this Architecture too. If a module needs to depend on 3rd party framework, we try to isolate this use by using wrappers and limit the use of this dependency only to one module. We explain this on an example inside the section bellow: Initial App Scaling -Adding Authentication Module with 3rd party framework

Note: In this article module and framework have equivalent meaning. because the way of creating a new module in Xcode is by creating a new framework. And DIContrainer — is a Dependency Injection Container. Example App of a feature module — means it is Demo App of this feature module and it is used to develop the feature.

Advantages of Modular Architecture

Builds times are faster. Changing one feature module will not affect other modules. And the app will compile faster by recompiling only the changed module. Compiling a big monolith app is much much slower than compiling isolating part(both clean and incremental builds). For example, our clean build time of all app is: 4 minutes vs Payments module example/demo app: 1 minute.

Development time is faster. The improvement is not only in compiling speed but also in accessing the screen that we are developing. For example, during module development from example project, you can easily open this screen as the initial screen in the app launch. You do not need to get through all the screens as it happens usually when we are developing in the main app.

Isolation of Change. When developing in modules there is clear responsibility of the area of code in the project, and when doing merge requests it is easy to see what module is affected.

Tests running in seconds because they still will run without host app, they will be in Pod of the module.

Module Dependencies rules:

Modules can depend on each other(without circular dependencies) and on 3rd party frameworks. (e.g dependency A<->B is not allowed)

A ->B->C means that module A which imports B will have access also to C

When creating a new module and this module depends on other feature that was not yet extracted into a separate module, we delegate this functionality to the main App using delegation or closures. For example, if the Delivery module needs to show chat for a user, we can create delegate func openChat(withUserId:itemId:onView:) inside Delivery module, and implement it inside main App and inject it into Delivery module.

On the other hand, if the Feature already exists in a separate module, we just configure it inside the main App and injected it into the module which we are separating.

Applying Modular Architecture on example project

Here we will separate the monolith App into Service and Feature, totally isolated modules. The monolith App is in this repo.

Before moving Movie Search Feature into a module, first, we need to move Networking Service into a separate module, because from this feature we will need to fetch movie items using Networking Service. It will be configured with the base URL and API key inside App and injected into the Movie Search Feature module(using DIContainer).

Separation Process of Service and Feature Modules

Moving Networking Service into an isolated module

First, we set up our project with pod init and pod install commands. Then we create the folder with the name DevPods inside the project folder. And inside this folder, we run pod lib create Networking command, which will create the module with the example project which is used to develop this module. After moving all module's code from the main app and configuring Podfile and module .podspec file (and running pod install), we have as a result an additional schema Networking-Example inside the main App workspace, which is used to develop this Networking module.

Now Networking can be developed separately by selecting schema:

Schemas, Project Pods groups, Podfile, Networking.podspec

Here we can see how module files are automatically copied into the app workspace Pod project by CocoaPods when command pod install is run:

Module files source and resources locations (workspace Pod project, folder, podspec)

Important: Detailed explanation of module creation can be found here: Steps for module creation with videos. Steps can be linked from readme.md file so every developer could easily create a new module.

Note: In case if we want now to share this Networking with more people and between many different projects is easy to share it in remote repository: https://github.com/kudoleh/SENetworking

Moving Movie Search Feature into an isolated module

Same as we did for Networking service, we run pod lib create MoviesSearch inside DevPods folder, which creates a new module with an example project to develop this module. We move all module related files into this module. After moving all module’s code from the main app and configuring Podfile and module .podspec file (and running pod install), we have as a result an additional schema MoviesSearch-Example inside the main App workspace, which is used to develop this module(on next picture). Note: this feature already has Clean Architecture and DIContainer, if not we would add it to the feature before moving it.

Now Movies search can be developed separately by selecting schema:

Schemas, Project Pods groups, Podfile, MoviesSearch.podspec

Here we can see how module files are automatically copied into the app workspace Pod project by CocoaPods when command pod install is run:

Module files source and resources locations (workspace Pod project, folder, podspec)

Inside App DIContainer we configure Networking with base URL and API key, and inject it inside Movies Search module:

Important: Detailed explanation of module creation can be found here: Steps for module creation with videos. Steps can be linked from readme.md file so every developer could easily create a new module.

Note: modules are local. They are located in the same repo(Monorepo) as the main app because we do not share this module yet with another project.

Localizable.string can be located inside the main app. All modules by default use the main apps bundle where these translations are located. If they are needed during development for an example project of a module, we can reference this file.

Making Entry Point for Movie Search Module

It is nice to see at one point what dependencies have a module and what function method it provides(to provide a front-facing interface, Facade pattern). We create Module struct and move here module Dependencies.

Inside Movie Search Module
DIContainer internal to MovieSearch Module
Inside App
Inside App AppMainFlowCoordinator

Project Source: https://github.com/kudoleh/iOS-Modular-Architecture .

Initial App Scaling -Adding Authentication Module with 3rd party framework

Nowadays many apps need to authenticate. So we add here Authentication Module. Here we will see how we can use 3rd party framework(Alamofire for authentication) from our local module(Authentication), and how we can limit the use of this 3rd party framework to one module only.

Modular Architecture with Authentication Module

In Authentication.podspec file we add s.dependency 'Alamofire'

Adding 3rd party framework dependency

The Authentication module contains code to do requests through Alamofire(SessionManager) which provides the functionality of authentification. (It has RequestAdapter and RequestRetrier where we add access token and refresh it)

Note: For not exposing the use of 3rd party framework(Alamofire) to other modules or to the main app we create AuthNetworkRequest wrapper. It is a wrapper around DataRequest(Alamofire class). Because when another module calls the func authRequest(:completion:) -> DataRequest, it depends on DataRequest class from Alamofire. This way we limit dependency on Alamofire to one module only(Authentication). Also, we omitted here the implementation of Authentication configuration(with auth base URL) and delegating user authorization to the main app(when login is needed).

In the heart of Networking module, we use Network Session Manager protocol to request data. We will make Authentication module(Auth Networking Session Manager) to conform to this protocol:

All requests are adapted inside the Authentication module, the token is added there, and if it fails it refresh the token and retries the request. This way Networking module does not know anything about Authentication. It makes sense because adding token and refreshing it, is an Authentication module concern. And no other module in the system should know about it (no Networking service, neither feature module).

Authentication Conformance to Networking and injection into Networking:

Note: In order to not depend from Authentication module on Networking module, we make AuthNetworkSessionManager protocol to conform to NetworkingSessionManager and we inject it into NetworkingService.

Project Source: https://github.com/kudoleh/iOS-Modular-Architecture

How Modular Architecture Scale

Here we describe an example of how modular architecture can scale:

Scaled Modular Architecture

In this graph we have the following modules:

Configuration module: Contains configuration for the whole app. Parameters like base URLs and feature flags for every country and every environment (stage or production) are stored in here plist. For example, the main app uses the base URL from the Configuration module to setup Networking Service and inject it into all features and services. It is used also in every feature module example to easily configure services used by the feature.

Networking Service: Simple wrapper around Network API request, it is configurable with parameters(e.g. base URL). It also maps decodable data.

Authentication Service: Authenticates all networking requests by adding and refreshing an access token. It depends on 3rd party framework Alamofire. Note: No other feature module has a dependency on this module, only the main App to configure it with the auth URL and inject it into Networking Service, by conforming to protocols of Networking. From all feature modules we only neet to use Networking Service to request data.

Tracking Service: Tracking service that makes tracking easy from all modules. It exposes only one func track(event:attributes:). Inside the module, it has the implementation of all trackings. As the Networking module, it is configured in the main app and injected in all modules.

Account Feature: Management of User Account (e.g. sign up, log in or update passwords).

Utils module: To share common utility functions and extensions.

UIComponents module: to share Common UI components and extensions related to UI.

Themes module: To share common images, colours and font. Note: For simplification, it can be part of UIComponents module to have less modules.

Feature module: Module with big enough feature and its own Clean Architecture, Domain and DIContainer. It depends on Networking Service to fetch data from the network and map it into domain entities. A feature can depend on other features. For example, we might need to open chat with a user from the Delivery module. (section above: Module Dependencies rules)

Common Domain (Core) module: To share common Use Cases and Domain Entities (e.g User entity with UserId and it's func isLogged, Money entity with its operations). Due to the fact that this module contains also Repositories implementation used by the Use Cases, it needs to depend on Networking module. It also can depend on Cache (3rd party framework to cache items) or on images cache Kingfisher used by ImagesRepo. Also it can depend on Utility module with common functionality.

Note: When we have two Apps, we can share between them shared modules (e.g. Networking, Tracking, Utils, UIComponents or shared feature).

Example Project with steps to create Modules

https://github.com/kudoleh/iOS-Modular-Architecture

Companies with many iOS Engineers

App modularisation is successfully used at fintech company Revolut with >70 iOS engineers.

Conclusion

It is used to break down our monolith app into totally isolated parts. It makes our development easier and faster, teams can work rapidly and independently

Even if your app is not big yet, it can become very big fast in the near future and it is much easier to start the app modularisation already now.

We create a module for big enough features. With its own domain and architecture(e.g. Clean Architecture +MVVM or Redux).

We isolate the use of 3rd party frameworks as much as we can, so there is no change in other modules when framework changes.

The modular architecture is equally applicable regardless of dependency management tools or platforms(e.g. Android or cross-platform like ReactNative or Flutter).

Pros of using modular architecture:

  • Faster build times (both clean and incremental)
  • Faster locating code in domain-related logic inside a module
  • Good decoupling and separation of concerns
  • Easier to do code reviews. It is easy to see what changed in an isolated module
  • Creating components separately is faster, also testing them in an example app and later integrating with the main app
  • Easier for new developers to onboard and create new teams that will work in an isolated module domain
  • It is important to keep all modules' example projects always buildable. We can use tools like Travis CI + Fastlane
  • Every isolated module feature have its own Dependency Injection Container and Module Dependencies to see all dependencies it needs in one point
  • Module can be built and shared as compiled framework
  • Unit Tests of a module are running super fast because they run without the host app (no simulator needed), and they are inside the module’s pod

--

--