Our team has been working on a large mobile app for a major telecom company. The task was to complete a project in a short amount of time. Take a look on how a proper technical approach allowed to enhance development.
The case behind this article aimed to complete a large-scale project in a short amount of time. Obviously, you just need to hire a lot of people, and then you have to make sure they don’t hinder each other. The latter is the most complicated: there is no way to make it happen without a good technical approach — and that’s what exactly we are going to talk about.
Our team has been working on a large mobile app for a major telecom company. The app has its “core” functionality (main use cases), and some extensions that offer additional use cases — but, essentially, the app can work without them. Further, we will refer to these extensions as “features”.
When the core part was ready, the customer asked us to speed up the development. Their argument was that since the core part is up and running, why would we develop the features one by one — it is more time-efficient to work on all of them in parallel.
We would have to scale up our team, and the customer would have to deliver us some more teams from the outside. Sounds cool, but so emerges a management problem: this team expansion leads to overhead costs growth and risks developers and/or teams getting in each other’s way.
Adding an outside team, or a number of teams, makes the job even more challenging, as it is not so easy to set up the collaboration for developers from different companies. The main challenges we faced were:
Shared code: Even if the developers from the same team make changes in the code, merge conflicts may occur — and more so if they are from different teams that communicate less often. Also, different teams may have their own code patterns and templates, that have to be unified for the entire project
Task management: Teams can use different management approaches or task trackers, so it can be quite hard to see the whole project status and synchronize task execution
Division of responsibility: In the case of bugs or problems, it is hard to figure out whose code caused the difficulties and who has to fix them
These challenges can be minimized if the code is as decoupled as possible — i.e. the project is divided into modules: one module for the main part and one module per feature. Module dependencies must be minimized too, in order to assign a module to a development team so that they could work independently from other teams. Ideally, you just specify an API for each module, make a mock implementation while the feature is still in development, and don’t even care about the implementation order. If the APIs are respected, a mock can be easily replaced with a fully-functional feature, and conversely.
Android can divide a project on its own — you can just make a Gradle library module for each feature. But the catch is that the module dependencies must include no circular dependency — otherwise, the project just won’t build. See the example:
We chose to keep it simple with no dependencies between the features, to avoid getting an accidental circular dependency. If one feature addresses another one, they interact through the main module as an intermediator. But even this way, there are some circular dependencies:
The main module provides the features with basic functionalities — network access, data storage, OS interaction, use the fundamental business logic of the app. Along with that, the main module embodies the business logic that is implemented in features. As an example, our app has a main screen that has widgets. These widgets are implemented in features, and the main screen is implemented in the main module.
There are also dependencies that are caused by navigation. In Android, UI and navigation are Activity-based, so features define Activities for its screens. An Activity must refer to another Activity’s .class to call it. So, if a main module Activity were to call a feature Activity, it is a dependency (and vice versa, if a feature Activity were to call a main module Activity).
Before studying the next diagram, let’s look at our development stack. We use Dagger 2 for dependency injection and Cicerone for navigation. In general, we follow the principles of clean architecture and use the MVVM pattern for implementing the presentation layer and RX for bindings.
Here is an example of our circular dependencies, shown on one feature for simplicity:
(Fig. 5. Some blocks will not be explained in detail, because they are not related to circular dependencies).
Data, Domain and Presentation are the basic layers of the clean architecture pattern.
DI is building a dependency graph using Dagger 2.
The main module has unclustered components (RxBus, App Lifecycle, Permissions, Utills), which are utilities, in-app event bus, and platform-specific tools.
The Presentation layer has view classes (Activity, Fragment) and a ViewModel (because we use MVVM).
The main module presents Base Presentation Classes, which are, for example, BaseActivity/BaseFragment/BaseViewModel/BaseFragmentNavigator. These classes implement bindings, dependency injection, and other boring things :)
Also this layer implements navigation. As we said before, we use Cicerone, so here we have:
AppNavigator: the main navigator of the entire application that routes between modules
MainNavigator: the navigator of the main module that navigates between the main module screens
ActivityNavigator: it moves between the given Activities (this navigator is used by AppNavigator and MainNavigator to essentially execute their commands)
The main module has the entire app’s business logic (Repositories, Interactors), and besides that, it has Repository Interfaces. Repository Interfaces are used by a feature to use another feature’s repository, or by the main module to use a feature’s repository.
This layer includes data storage, fetching, and caching. There is nothing very special in this layer: the Feature module here depends on the main module, getting access to the database, network, etc.
This layer is where the dependency graph is formed. The figure above only shows the problem area and does not include modules that provide network-, database-, business logic-related, and other dependencies. FeatureComponent depends on AppComponent to get the needed dependencies into the Feature module. AppComponent must provide repositories that have their interfaces defined in the App module and their implementation defined in the Feature module.
App -> Feature:
Inheritance from Base Presentation Classes
Each Activity must have an ActivityNavigator field to execute navigation commands at any time
Repository interface implementation
Using the shared business logic of the entire app
Using utilities, in-app event bus, and platform-specific tools
FeatureComponent depends on the AppComponent
Feature -> App:
Using a View from the Feature module. (As mentioned, our app has a main screen with widgets on it. These widgets are implemented in features, and the main screen is implemented in the main module.)
Using the Activity’s .class from the Feature module in ActivityNavigator
Performing repository constructor calls to create a FeatureModule and provide those repositories to the AppComponent (because they are used by the main module and other features)
The basic idea of getting rid of a circular dependency is adding an extra element:
In our case, it is not as easy as in this three-circle diagram, but the idea is the same. If we divide the modules into parts for presentation and logic, plus add the “common” module, the dependency problem is solved. An added bonus is less module cohesion, which makes it easier to develop the logic and presentation separately.
Contains view-classes of the feature module
Contains Activities that are declared abstract, because they cannot contain a navigator
Contains view-classes of the main module
Inherits the abstract Activities from the feature module, adding a navigator to them,
Contains business logic, ViewModels and the navigation logic of the feature module, implements the interfaces of the repositories that are moved to the common module
Contains business logic, ViewModels and the navigation logic of the main module
Implements the interfaces of the repositories that are moved to the common module
Calls the repository constructors from the feature module to form a dependency graph
Contains interface declarations for those repositories/interactors/etc. that are used in more than one module
Contains Base Presentation Classes
And for the most patient readers, here is our final architecture diagram:
Words are cheap, show me the metrics. Now we can prove that turning the project upside down and splitting it between teams ended up being more efficient than completing it with the same team.
The first stage, before splitting into modules:
37296 lines of code, 104 days of work, 7 people in the team.
Rebuilding the architecture and adding new teams to the project took 14 days.
The second stage, with added teams and modules:
72043 lines of code and 92 days of work. If we kept working in our seven-people team, it would take us 201 day, which is 109 more days — more than twice as long.
We understand that this calculation is rough, and there are lots of subtle things we overlook — such as teams getting familiar with the project, overhead costs for management and interaction, refactoring, and so on. But still, this calculation is an evident way to show how our approach was useful for this exact project.
Thanks for reading, we hope you got some useful insights!
Let us tell you more about our projects!