MagicLab is developing several applications (i.e: Badoo, Bumble, Chappy and Lumen) and each one of them is a separate product with its own features, management, product and engineering teams. However, we all work together in the same office and solve similar problems.
Nevertheless, projects have diverged greatly during development. The codebase was influenced not just by different time frames and product decisions, but by the vision of the developers as well. In the end, we noticed that projects have the same functionality, which substantially differs in implementation.
We then decided to move towards a structure which would give us the opportunity to reuse features in different applications. Now, instead of developing functionality for individual projects, we create common components that can be integrated into all of the products. If you’re wondering how we got there, welcome to the rest of the article!
But first, let’s explore the issues which prevented us from creating common components from the start. There were several of these:
- copy pasted code between applications
- outdated processes
- differences in project architecture.
Issue: copy pasted code
Some time ago, when the earth was still green, we had a recurring story:
There is a developer, let’s call him Alex. He makes a cool module for his task, tells everybody inside the company about it and puts it in the repository of his application, where he uses it.
The issue here is that all of our applications are in different repositories.
Andrew, another developer, is currently working on another application in another repository. He wants to use this module in his task, which is suspiciously similar to the one Alex was working on. But there is a problem: reusing the code is not that simple, as the repositories are not linked in any way.
In this situation, Andrew will either write his own solution (which happened in 80% of cases), or will copy and paste Alex’s solution and change everything within it to better suit to his application, task or mood.
After that, Alex can update his module, adding changes to the code for his task. He doesn’t care much about the other version and so only updates his repository.
This situation leads to several problems.
First, we have multiple applications, each with its own vision. Therefore, product teams often create solutions that are difficult to merge into a single structure.
Second, projects are handled by individual teams who don’t communicate much with each other and therefore rarely share updates/inform each other about reusing a module.
Third, the application architecture varies significantly: from MVP to MVI, from god activity to single activity.
And the final problem: applications are put into different repositories, and teams stick to their own processes.
When we started dealing with these problems, we set ourselves the final goal: to be able reuse our code (both logic and UI) in all of the applications.
Solutions: we are establishing processes
Two of the problems mentioned above are related to processes:
- Two repositories that separate projects with an impenetrable wall.
- Separate teams without established communication and different requirements for product application teams.
Let’s start with the first one: we are dealing with two repositories which need to reuse the same module. In theory, we could use git-subtree or similar solutions and put common project modules into separate repositories.
The problem occurs during modification. Unlike open-source projects, which have a stable API and are distributed through external sources, changes in internal components most likely will break everything. By using resorting to artifacts, each of these migrations become a pain.
My colleagues from the iOS teams have had a similar experience, and with not a very successful outcome. Learning from their experience, we switched to a single repository.
All Android apps are now in the same place, which gives us certain advantages:
- you can easily reuse the code using Gradle modules
- we were able to reuse the toolchain on CI, resulting in the same infrastructure for builds and tests
- most importantly, these changes have eliminated the physical and somewhat mental barrier between the teams, as we are now free to use parts of other apps.
Naturally, this solution also has its disadvantages as well. We have a huge project which is sometimes beyond the limits of IDE and Gradle. The problem could be partly solved by the Load/Unload modules feature in Android Studio, but it is difficult to use when you need to work on all of the applications simultaneously. There’s certainly room for improvement there, and we are working on it.
The second problem concerns team interaction. This can be broken down into several parts:
- separate teams without established communication practices
- unclear responsibility for common modules
- different product teams requirements.
In order to solve this, we have put together teams that are working on implementing specific functionality for each application: for example, separate chat or registration teams. In addition to the development of independent modules, they are also responsible for integrating them into the application.
Product teams already have these components at their disposal and are improving and customising them to suit the needs of a specific project.
As such, the creation of a reusable component is now part of the process for the entire company, from ideation to production.
Solutions: the architecture
The next step towards reusability was to build a common architecture. Of course, you may ask: “But why?”
Our codebase contains the legacy of several years’ worth of development. Along with time and people, best practices were changing. Consequently, we found ourselves in a situation with myriad architectures, which resulted in the following problems:
- Integrating common modules was almost slower than writing new ones. Apart from adapting functionality, the structure of each application had to be taken into account.
- Developers who had to frequently switch between applications spent a lot of time figuring out how the same thing works everywhere.
- Wrappers were often written from one architecture to another, which accounted for almost half of the code during module integration.
We finally settled on the MVI approach, which was structured in our MVICore library (GitHub). We were especially interested in one of its features, atomic state updates, which ensure valid state at all times. We went a little further and combined the presentation and logical state layers, reducing fragmentation. This resulted in a structure where a single entity is responsible for the logic, and the view only displays the model created from the state.
Layers are divided through the transformation of models between elements. Thanks to this, we get an additional benefit in the form of reusability. We connect everything externally, which means that each of elements is unaware that the other exists. They emit certain models outside and react to whatever comes to them. This makes it possible to extract components and use them elsewhere by writing adapters for their models.
Let’s take the example of a simple screen and examine how it looks in reality.
We use base RxJava interfaces to indicate the types that the element works with. Input is denoted by the interface
Consumer<T>, output by
Using these interfaces, we can express View as
ObservableSource<Event>. Note that ViewModel here is a data class with the screen state and has little to do with MVVM. Once the model is received we can show the data from it in the TextView. When the button is pressed, the event is emitted outside.
The Feature already implements ObservableSource and Consumer for us; we need to define the initial state there (counter, equal to 0) and specify how the state can be updated.
After the Wish is received, the Reducer is triggered, which creates a new state based on the previous one and the contents of wish. Besides Reducer, the logic can be described using other components. Further information about them can be found here.
Having created two elements, we then need to connect them.
First, we indicate how we transform one type of element into another. ButtonClick becomes Increment, and the counter field from State is converted to text.
Finally, we define each of the chains with the desired transformation. To do this we use Binder. It allows you to create links between ObservableSource and Consumer, while protecting the life cycle, and all of this with a nice syntax. These links give us a flexible system that allows us to extract and reuse elements individually.
MVICore-elements work rather well with our zoo of architectures after writing wrappers to ObservableSource and Consumer. For example, we can wrap the methods of Use Case from Clean Architecture to Wish/State and use in the chain instead of Feature.
Finally, we get to components. What are they exactly?
For greater understanding, let’s take a look at the screen in the application and divide it into logical parts.
We can define the following parts:
- toolbar with buttons and a logo at the top
- card with the profile and logo
- Instagram section.
Each of these parts is a component that can be reused in a completely different context. For example, the Instagram section could become part of the profile editing in another application.
In general, a component is several elements from presentation and logic layers (and maybe other components nested inside, why not?), unified by a common functionality. After starting to look at them this way, the very first problem we encountered was that MVIcore helps to create and link elements but offers no structure. When elements from a common module are reused, it is unclear where these pieces should be assembled: inside the common part or on the application side?
In general, we definitely want to avoid giving the application an assortment of pieces. Ideally, we are aiming for a structure which will be able to receive dependencies and assemble the component into something solid with the necessary lifecycle.
Initially we divided components by screens. The elements were connected alongside the creation of DI containers for activity or fragment. These containers are already aware of all of the dependencies and have access to the View and the lifecycle.
Problems appeared in two places right away:
- DI was working with the logic, which led to the description of the entire component in one class.
- Since the container is attached to an Activity or Fragment and describes at least an entire screen, there could be a lot of elements on such a screen/container, resulting in a huge amount of code to connect all of the dependencies of the screen.
Solving problems in order, we began by placing the logic in a separate component. This means we are able to assemble all of the Features inside the component and communicate with View through input and output. As for the interface, it looks like a normal MVICore element, but it is created from several others.
Now we have extracted responsibility for connecting the elements into the common module, which was something we were striving for. However, we still divided components into screens, resulting in a huge number of dependencies in one place.
The right solution in this situation is to break up the component. As we saw above, each screen is composed of a large number of logic elements which we can divide into independent parts.
After some reflection, we decided upon a tree structure and, naively building it from the existing components, we got the following graph:
It is virtually impossible to maintain the synchronisation between two trees (View and the logic). However, if the component is responsible for displaying its View, we can simplify this. Having studied the existing solutions, we rethought our approach, learning from Uber RIBs.
The ideas behind this approach are very similar to those of MVICore. RIB is a kind of “black box”, communicated with via a strictly defined interface of dependencies (i.e. input and output). Despite the apparent complexity of supporting an interface in a rapidly iterating product, there are great opportunities for reusing code and defining common structure.
As such, compared to previous iterations, we get:
- encapsulated logic within a component
- support for nesting, which makes it possible to divide the screens into parts
- interaction with other components through the strict interface of input/output with support of MVICore
- compile-time safe connection of component dependencies (relying on Dagger as DI).
But that is not all, of course. The repository on GitHub contains a more detailed and up-to-date description.
And so we have our perfect world, right? It has components that we can use to build a fully reused tree.
No, because we live in an imperfect world.
Welcome to reality!
In the imperfect world we live in, there is a whole bunch of things that we have to reconcile. We are concerned about the following:
- different functionality: despite all of the alignment, we are still dealing with different products with different requirements;
- support: as always, new functionality under A/B tests
- legacy (everything that was written before our shiny new architecture)
The complexity of these problems increases exponentially since each application adds some different requirement to the common components.
Let’s use the registration process as an example of a generic component that is integrated into applications. In general, registration is a chain of screens with actions affecting the entire flow. Each application has different screens and its own UI. The ultimate goal is to make a flexible reusable component which will also help us to solve the issues mentioned above.
Each application has its own unique registration variations, both in terms of logic and UI. Therefore, to extract common functionality of the component, we start with basics: data download and routing of the flow.
Such a container passes data from the server to the application, which converts data into a finished screen with logic. There is only one requirement: screens passed to such a container must comply with an interface in order to interact with the logic of the entire flow.
After managing this trick with a couple of applications, we noticed that the logic of the screens is nearly the same. Ideally, we would create a common logic by customising the View. The question is how to customise them.
As you might remember from the MVICore description, both View and Feature are based on an interface from ObservableSource and Consumer. Using them as an abstraction, we can replace the implementation without changing the main parts.
Thus we reuse the logic by dividing the UI. As a result, support becomes much easier.
Let’s consider an A/B test for the variation of visual elements. In this case, our logic does not change, which enables us to substitute another View implementation under the existing interface of ObservableSource and Consumer.
Naturally, sometimes the new requirements contradict the logic that has already been written. In such cases, we can always return to the original idea, where the application returns the entire screen. This is a kind of “black box” for us, and it doesn’t matter what is transmitted to the container as long as its interface has been implemented.
As we have noticed on practice, most applications use Activity as basic units, the means of communication between which have been known for a long time. All we had to do was learn how to wrap our black boxes with the Activity and pass data through the input and output. It turned out that this approach works well with fragments too.
Nothing changes very much for single-activity applications. Virtually all frameworks offer their basic elements in which RIB components can be wrapped.
By going through these steps, we have significantly increased the percentage of code that is reused throughout our company’s projects. At present, the number of components is close to 100, and most of them implement functionality for several applications at once.
Our experience shows that:
- despite the increased complexity of designing common components (given the requirements of different applications) their support is much easier in the long run
- by building components separately from one another, we have made it significantly simpler for them to be integrated into applications built on different principles
- revising processes, along with focusing on developing and supporting components, has a positive effect on the quality of the overall functionality.