Modularization in Android: architecture point of view. From A to Z. Part II

Modularization articles cycle:
- Modularization in Android: architecture point of view. From A to Z. Part I
- Modularization in Android: architecture point of view. From A to Z. Part II
Hello everyone!
Let’s continue to dive into Modularization! If you haven’t read the first part yet you should do it right now :)
”Brand new life”
Now we will try to gradually implement the above-mentioned (in the previous article) wishlist. Let’s go!
DI Improvements
Let’s start with the DI.
Reducing the number of scopes
As I wrote above, my previous approach was about having a separate scope for every feature. In fact, there are no big benefits from this approach. You’ll just get a large number of scopes and a certain amount of a headache.
The following chain will be quite sufficient: Singleton — PerFeature — PerScreen.
Favoring Component dependencies over subcomponents
This is a more interesting approach. With Subcomponents, you seem to have a stricter hierarchy, but at the same time, your hands are completely tied up and there is no way to maneuver. In addition, AppComponent knows about all the features, and you also get a hugely generated DaggerAppComponent class.
With Component dependencies you get one super cool advantage, which is you can specify pure interfaces as dependencies instead of components (thanks to Denis and Volodya). Because of this, you can substitute any implementation of the interface, and Dagger will digest anything. Even if this implementation is a component with the same scopes:
From DI improvements to the better architecture
Let’s recall the definition of a feature. A feature is a logically complete, maximally independent program module that solves a specific user problem, with clearly defined external dependencies, and which is relatively easily can be reused in another application. One of the key expressions here is “with clearly defined external dependencies”. Therefore, let’s define all outer world dependencies for the features in a special interface.
For example, this is external dependencies interface for Purchase feature:
Or external dependencies interface for Scanner Feature:
As already mentioned in DI section, dependencies can be implemented by anyone and in any possible way, these are pure interfaces, and our features are exempted from this extra knowledge.
Another important component of the “clean” feature is the presence of a clear API, to which the outside world can refer.
This is Purchase Feature API:
That means the outside world can get a PurchaseInteractor and try to make a purchase through it. As you might have seen above, the Scanner needs a PurchaseInteractor to make a purchase.
And this is ScannerFeature API:
ScannerStarter API and implementation right away:
It’s more interesting here. The fact is that the scanner and anti-virus are quite closed and isolated features. In my example, these features are launched on a separate Activity, with its own navigation, etc. We simply need to start Activity here, and whenever Activity is killed — the feature is killed as well. You can utilize “Single Activity” approach here, and then you pass through FragmentManager and some callbacks, and when a feature has ended it will inform via the callbacks.
We can also say that such features as Scanner and Anti-Theft can be considered as independent applications. Unlike Purchase feature, which is an additional feature to some other independent component and cannot really exist by itself. Yes, it is independent, but it is a logical addition to other features.
As you might have guessed, there must be some point that binds an API, its implementation, and required dependencies for the feature. This point is the Dagger component.
Scanner Feature Component example:
I assume there’s nothing new here.
Transition to multi-modularity
So far, we managed to clearly define the boundaries of the features through the APIs of its dependencies and the external APIs. We also figured out how to handle it in Dagger. And now we’re coming to the next logical and interesting step — dividing into modules.
Open the test example right away — it will be easier to follow.
Let’s look at the whole picture first:
And also check out the packages structure:
And now let’s discuss each item in details.
First of all, we see four large blocks: Application, API, Impl and Utils. In the API, Impl and Utils, you may have noticed that all modules begin either with core- or with feature-. Let’s first talk about them.
Core and feature separation
I divide all modules into two categories: core- and feature-.
Inside feature-, as you might have guessed, we have our features. In the core-, there are such things as utilities, network handling, database, etc. But there are no interfaces of features there. And the core is not a monolith. I am for splitting the core module into logical pieces and against loading it with some other features interfaces.
As the title of the module, we first write core or feature. Next, we add a logical name (scanner, network, etc.).
Now about four big blocks: Application, API, Impl and Utils
API
Each feature- or core-module is divided into API and Impl. The API has external APIs through which you can access the feature or core. Only this, and nothing more:
Moreover, the api-module does not know anything about anyone, it is an absolutely isolated module.
Utils
The only exception to the rule above is utility functions that are meaningless to break into api and implementation.
Impl
Here we have subdivisions to core-impl and feature-impl.
Modules inside core-impl are also completely independent. Their only dependency is the api-module. As an example, let’s look at the build.gradle file of the core-db-impl module:
feature-impl will have an essential part of the application logic. The modules of the feature-impl group may know about the modules of the API or Utils group, but they definitely don’t know anything about the other modules of the Impl group.
As we remember, all external dependencies of a feature are accumulated in their APIs. For example, scan feature will have the following APIs:
Therefore, build.gradle of feature-scanner-impl will look like this:
You may ask, why api module doesn’t contain APIs of external dependencies? The reason is, it is an implementation detail, i.e., this exact implementation requires some specific dependencies. For Scanner, dependency APIs will be here:
Brief architectural excursus
Let’s digest all of the above and clarify for ourselves some of the architectural aspects of feature -…- impl-modules and their dependencies on other modules.
I have seen two popular patterns for setting up module dependencies:
- The module can know about anyone. There are no rules, thus there is nothing to even comment on.
- Modules only know about the core module. And core-module has the interfaces of all features. This approach does not really appeal to me, as there is a risk of turning the core into another garbage bin. In addition, if we want to transfer our module to another application, we will need to copy these interfaces to another application, and also place it in the core. By itself, the blunt copy-pasting of interfaces is not very attractive and reusable in the future, where interfaces can be updated.
In our example, I advocate for the modules that know about API modules only (well, apart from utils). Features know absolutely nothing about the implementation.
But it turns out that features can know about other features (via api, of course) and execute them. Will not we end up with a mess?
Fair remark. It’s hard to work out some kind of super-clear rules. There should be a balance. We have already touched this issue a little bit above, dividing the features into completely independent and separate (Scanner and Anti-Theft), and features “in context”, which is always launched within some other independent feature (Purchase) and usually implies business logic without ui. That is why the Scanner and Anti-Theft are aware of Purchases.
Another example. Imagine that there is such a thing as wipe data in Anti-Theft, which is clearing absolutely all data from the phone. There is a lot of business logic there, has some UI, and it is completely isolated. Therefore, it is logical to move wipe data into a separate feature. And here we have options. If wipe data is always launched only from Anti-Theft and is always present in Anti-Theft, then it is logical that Anti-Theft would know about wipe data and launch it on its own. And the accumulating module, the app, would then know only about Anti-Theft. But if wipe data can be launched from somewhere else or is not always present in Anti-Theft (it can be different in different applications), then it’s logical that Anti-Theft does not know about this feature and just informs something external (via Router, or some kind of callback) that the user pressed such a button, and what to launch is a responsibility for the consumer of the Anti-theft feature.
Also, there is an interesting question about transferring features to another application. If we, for example, want to transfer the Scanner to another application, then we need to also transfer the modules on which the scanner depends (:core-utils, :core-network- api, :core-db-api, :feature-purchase-api) in addition to the :feature-scanner-api and :feature-scanner-impl modules.
Yes, but first of all, all api-modules are completely independent, and there are only interfaces and data models. There is no logic. And these modules are separated logically, and :core-utils is usually a common module for all applications.
Secondly, you can build api-modules as aar’s and deliver them via maven to other applications, or you can connect it as a git sub-module. This way will give you versioning, control, and integrity.
Thus, reusing of a module (more precisely, the module-implementation) in another application looks much simpler, clearer and safer.
Application
It seems we have a clear picture with features, modules, their dependencies, and all others. Now we come to the climax — connecting APIs with its implementation, substituting required dependencies, and so on, but now from the Gradle modules perspective. The point of connection is usually the app itself.
By the way, in our example, such a point is still the feature-scanner-example. The above-mentioned approach allows you to run each of its features as a separate application, which greatly saves assembly time during active development. What beauty!
For the beginning, let’s look at how the app can be a starting point for our already beloved Scanner example.
Let’s quickly recall the feature:
Extra dependencies API of Scanner is:
That’s why :feature-scanner-impl depends on the next modules:
Based on this, we can create a Dagger component that implements the APIs of the external dependencies:
For convenience, I have placed this interface in ScannerFeatureComponent:
Now App knows about all the modules it needs (core-, feature-, api, impl):
Next, let’s create an auxiliary class called FeatureProxyInjector, and it will help us to correctly initialize all the components, and via this class we will access the feature APIs. Let’s take a look at how the feature of the Scanner is initialized:
We give customers a feature interface (ScannerFeatureApi), while inside the module we initialize the entire dependency graph (using ScannerFeatureComponent.initAndGet(…) method).
DaggerPurchaseComponent_PurchaseFeatureDependenciesComponent is an implementation of PurchaseFeatureDependenciesComponent generated by Dagger discussed above, where we set api-modules’ implementations to the Builder.
That’s all magic. Look at the example again.
By the way, there is a couple of things to say about example. In example, we have to implement all extra dependencies of :feature-scanner-impl. But in our case, we can set empty classes instead.
It will look like:
In order to avoid creating unnecessary empty Activities, we start Scanner feature via manifest:
The algorithm of transforming your Mono-modular project to Multi-modular one
Life is hard. There is a reality where we work with a legacy. If you are starting a new project where you have authority to make all thing correctly, then I envy you, bro. But I don’t have such a privilege and that guy doesn’t have either :-)
How to transform your application to Multi-modular one? I’ve generally heard about two approaches.
The first approach is splitting an app to modules right now and right here. But your project may not compile for several months :-)
The second approach is to try to pull out features gradually, step-by-step. Additionally, you will pull out the external dependencies of these features. And here it becomes quite challenging, as those dependencies may affect the code in other places. You start migrating this code to common-module, core-module back and forth, and repeat these actions constantly until you get expected result. As a summary, withdrawing one feature could lead to refactoring of half of your app. And again, your project might not be compiling during this time.
I am a proponent of gradual transformation to multi-modularity because you cannot spend all your time doing it, as you might need to implement new features as well. The main idea is if your module has a dependency, do not move it to your module immediately. Let’s look at the algorithm of pulling out the features in the example of Scanner:
- Create feature APIs, put it to new api-module. That means creating a :feature-scanner-api module with all required interfaces.
- Create :feature-scanner-impl. Add all the code related to the feature to this module. All the dependencies of your feature will be highlighted in Android Studio immediately.
- Identify the feature’s external dependencies, and create appropriate interfaces. Split those interfaces to logical api-modules, i.e. in our example it means to create such modules as :core-utils, :core-network-api, :core-db-api, :feature-purchase-api with appropriate interfaces.
I suggest thinking carefully about names and meaning of the modules from the beginning. Those interfaces and modules may get changed over time, however having a good naming will help you a lot. - Create APIs for external dependencies (ScannerFeatureDependencies). Set recently created api-modules as dependencies of :feature-scanner-impl.
- As our legacy code is inside app module, we include all modules created for feature-module (feature api-module, feature impl-module, external dependencies api-modules) as a dependency for the app.
As a very important detail here, we need to create implementations of all required interfaces of feature dependencies (It’s Scanner in this example) in app-module. These implementations will probably simply proxy your api dependencies to current implementations of dependencies in the project. During feature component initialization, you have to put above-mentioned implementations.
It is probably difficult to understand explanations, thus there is a sample project. Something similar exists in feature-scanner-example. Once again I am going to show a slightly adapted code:
- So, the main idea here is as follows. All external code required for the feature needs to be inside the app like it was before. But our feature will be working with external code through APIs (dependencies APIs and api-modules). Later, implementations will move to separate modules gradually. As a result, we avoid infinitive moves of the external code required for the feature between modules. And this approach can be done with iterations!
- Profit
That is a simple but working algorithm, which will allow you to move to your target step by step.
Additional advices
How big/small features should be?
Depends on the project, however, at the beginning of transforming to multi-modularity, I suggest making big features. Later on, if necessary you can decompose these modules to smaller ones. But do not go to an extreme, there is no need for making modules for one/few classes.
Clarity of the app-module
While moving to multi-modularity, your app module will be quite big and it will be a place where your separated features will be called. Also, it is possible that during transformation period you might need to make modifications to your legacy code, or due to release you might not have enough time to spend on features. In such cases, you should ensure that your app module along with the legacy part is aware of external features only via APIs, but not via implementations. But you might say that app connects api- and impl- modules, thus it is aware of everything.
If that is a problem for you, you can create a special :adapter module whose job will be to connect api and impl modules, then app will know about APIs. I hope you got the idea. You can look at the sample in clean_app branch. I have to say, there were some problems with Moxy library (if to be specific, with MoxyReflector class) while transforming to multi-modularity, and it has been solved by adding one more module named :stub-moxy-java.
As a side note, this will work only when your feature and its corresponding dependencies are moved to separate modules. If you have moved a feature, but its dependencies are still inside app as shown above, it will not work.
Afterward
The series of articles became too large, but I hope it will help you with your struggle with single-modularity, with an understanding of how it should be and how to make it work with DI.
I will be glad for any comments, corrections, and like. Wish you a happy, clean coding!