How to Modularize an Android or iOS application
The Microservices architecture first emerged in the backend ecosystem to cover the needs of independent developability, maintainability, and deployability. This blog post will examine how this idea can be ported and used in Mobile development.
Before we get to Microservices, we first need to understand modularity. Modularity essentially means splitting an extensive application into smaller ones by adding meaningful boundaries inside the codebase. We want to do that in order to lower the system's coupling. To find out why reducing the system's coupling is essential, you can check out my previous blog post on cohesion & coupling.
But what are those so-called boundaries? A boundary is anything that separates code. It can be soft or hard, depending on how easy it is to cross it. Technically, segregating code into two functions is drawing a boundary between them.
The second softest type of boundary is two concrete classes under the same package or module, with one depending on the other. Private functions cannot be called, but the classes can depend on each other directly without an import statement. We can muscle up this boundary by separating those classes into different packages. Now crossing the boundary involves importing the dependency. If the implementations change, though, the dependent class compilation might break.
To further increase the strength of our boundaries, we can draw interfaces between the concretions. This way, a concretion cannot change at will and cannot accidentally break other concretions. Still, this boundary can be bypassed if the classes reside under the same software module. We cannot prevent “illegal” boundary crossings. An implementation can, against the rules, import and use the other concretions.
The dotted line represents a boundary crossing against the rules.
The most rigid boundary type is when we draw an interface and segregate the code into different modules. Those modules can be developed, built, and deployed independently. They have their own build.gradle, or cocoa pods / Package.swift file. Those build files contain all the allowed cross-module dependencies. Those are hard boundaries.
When we talk about Microservices, we refer to adding a lot of hard boundaries in a meaningful way in order to create several micro-apps or micro-services. In the context of a mobile app, a hard boundary, as we explained, is an independent Android module or iOS target. Let's first discuss why we want to do that, or better yet, when we want to do that.
Having rigid boundaries in the code base maximizes each part's independence. Independence can help us in the following ways:
Enables independent teams. In a scaled organization with several development teams working on the same app, productivity suffers when everyone works on a shared codebase. The most obvious reason is the pesky cross-team merge conflicts. Those can become a pain to solve without breaking each other's functionality. Furthermore, each team should have a clear boundary of control for which it is responsible. It should be enabled to choose the programming language, architecture, and third-party libraries that best suit its devs and the problem they are trying to solve.
Contains cancer. Cancer refers to legacy, poorly architected parts of the app. An effective way to move forward is to isolate them with a hard boundary and develop the new features in independent Feature modules. Cancer-free.
Enables experimenting. We can use the technique of Feature modules to try out a new architecture or third-party library in a "safe lab" without putting the rest of the app at risk.
Having said the above, it's also important to realize that hard boundaries also come at a cost. Drawing interfaces and transforming data from one side of the system to another requires effort. This effort is a well-placed investment when one or more of the goals above are achieved. It shouldn't be considered a "best practice" in any situation. Particularly in a small team of one or two developers with no cancerous area to contain or an experiment to be made. Softer and more flexible boundaries can serve us better there.
Avoid starting with hard-boundary architectures like Microservices or Feature modules. Add soft boundaries with packages, so you reserve the option to make them more rigid when needed.
Why? Because hard boundaries are hard to place and even harder to displace. Once we add part of the logic in a Microservice, moving it alongside all of its dependencies to another Microservice is hard. Harder than cutting and pasting classes from one package to another.
Soft boundaries are easier to refactor as we learn more about the system. We should only make them rigid when the need arises.
There are countless ways to draw boundaries in a system and thus segregate the codebase into Microservices. The meaningful ones manifest themselves alongside two axes:
Assuming that all boundaries are rigid, we get the four following architectural categories:
The layers' axis is technical-specific and domain-agnostic. The main decision factors are the complexity of our app and the level of decoupling we want to achieve. For a complex app, complex enough to require Microservices, I usually use the layering below:
First of all, notice that the diagram has thick and thin walls. The thick walls represent hard boundaries while the thin walls soft boundaries. Furthermore, we are following Clean Architecture's Dependency Rule: Inner layers are not allowed to know anything about outer layers. Let’s break it down:
Application. Contains all the domain and business logic. It's softly segregated from the Entities living at the domain's center.
Data. It contains the APIs for data access and is responsible for data aggregation.
Presentation. Responsible for applying the application-specific presentation logic.
Framework. It contains all the Framework-dependent code. Anything that derives from an Android or iOS framework like Activities, Fragments, ViewControllers, SQLite, etc...
When the segregation factor is the layering, we implement layer-based modules, e.g., PresentationModule, DataModule, etc.
By drawing hard boundaries between the layers, we effectively prevent architectural violations. An inner layer is blocked from importing dependencies from an outer later. Cross-module imports must be explicitly defined in build.gradle or Architecture.xcodeproj. This way, a junior dev cannot accidentally import a class belonging to the framework module in the domain module:
On the downside, it doesn’t scale well. In a large app, those modules will eventually turn into code buckets. As the number of features increases, we will have to add too many classes in each layer. Another disadvantage is that this project’s facade says nothing about the system’s intention. It does not express whether it belongs to a banking or healthcare app. It’s the opposite of what Uncle Bob refers to as Screaming Architecture.
The features' axis is domain-specific and technical-agnostic. In order to draw the boundaries, we need to sit down with the rest of the team(s) and decide what constitutes a feature. The rule is that a feature must be logically cohesive. For example, subdomains like payments or profiles typically can be extracted into independent Feature modules.
Adding hard boundaries on features, we get Feature modules:
With this approach, our app scales much better. As the number of features increases, so do the boundaries and modules. Therefore, we can maintain low coupling. Each module can be completely independent and agnostic of all others. The app/main module depends on all of them and orchestrates the navigation between them. This way, we can enable independent teams to work in parallel; each team can own specific module(s). Consequently, each team can also decide on third-party libraries and coding styles.
On the downsides of the Feature module approach, we lack rigid boundaries between the layers. For example, Fragments and Use Cases live under the same module. Thus, they can both import and use each other directly.
Finally, when we decide to draw hard boundaries, independent modules on both axes, an Android app looks like this:
This approach draws rigid boundaries both on features and on layers axes. We get the benefits of both, and we produce the least possible coupling. Each module now is tiny and focused, essentially serving as a micro-service or micro-app. The architectural layers are protected, and the project can scale well into multiple features. You can have several independent teams owning specific feature(s). And even decide to have UI devs within the teams who take care of the framework layer and different devs to take care of the application logic. A backend dev who knows Kotlin could contribute to the application layer while being utterly Android-agnostic.
On the other hand, it increases the app’s complexity and reduces its flexibility. Everything is isolated. It's not a recommended approach for a small startup. It works better in large enterprises.
Okay, so we explained how the Microservices architecture can be ported to the mobile platforms and provide the advantages of independent developability, maintainability, and testability. But how about independent deployability?
The mobile platforms work quite differently here than the backend systems. In a backend system, you are free to produce multiple executables that will run on different (virtual) machines. On the other hand, a mobile app is supposed to run on a single mobile phone, so we need to produce and install a single executable. Let's touch on the testing and production phases separately to get the whole picture.
In the testing phase, when we build and run our application, we produce a single executable that gets installed into our mobile device. The build systems are smart enough to track which modules have been changed since the last build and only re-build those. Therefore, a Microservices architecture can dramatically reduce the build times of an extensive application.
In the release phase, in both Android & iOS, we have to upload a single executable to Google Play or Appstore, respectively. Uploading specific modules is currently not supported. What we can achieve, on the other hand, is independent downloadability. We can make use of the Android dynamic features and iOS App Clips. Those essentially allow the main module to be downloaded and run initially. The rest of the modules can be installed later, on user demand. The Microservices architecture can significantly facilitate this process.
The Mobile Microservices Architecture is taking the concept of modularity to the extreme. It provides both the advantages of Feature Modules and Layer separation but, on the other hand, makes the codebase inflexible. It enables an application's independent maintainability, testability, and downloadability. The mobile platforms can't yet support independent deployability. But when we get this possibility, a modularized app will be ready to take advantage of it.
It best suits large corporations with several independent teams working on a single app. Avoid it on other occasions and opt-in for Feature Modules or even simpler approaches.
Finally, if you are interested in deep diving into the concept further, check out the book Clean Mobile Architecture.