How To Setup a Clean and Scalable Architecture in Android and Kotlin for Multiplatform Mobile Apps | by Mukul Jangir | Aug, 2022

A guide to help you develop easily

There has always been an open debate on which architecture we should choose and why we need architecture, blah, blah, blah. In Android development, people are always confused when choosing MVVM, MVP, and MVC architecture.

But yeah! Most people choose MVVM because of the model-view-view-model relation. But in reality, choosing any of them is not mandatory. You can try out the architecture that suits your needs. Here, we will target an architecture design that suits large projects and uses the full power of development. In the end, you will find the architecture you were following was not as good as your own. So, let’s open the secrets of architecture.

Lots of people think we should write clean code. Of course, we should. But if we don’t understand clean code principles, it will become a serious problem/bug. Because while writing clean code, we only consider minimizing the code by putting some code into a separate class.

If you’re an Android developer, you should be careful while putting code into separate classes. Because it comes with the cost of thread safety, memory leaks, parallel execution issues, etc. Also, in Android, most things use context. It’s a bad practice to pass the context to another class.

Clean architecture solves the complexity of projects in a simpler and scalable form. In clean architecture, we separate the codebase of an app into different layers at different levels/scopes. By using SOLID principles, we can make it more beautiful and scalable. SOLID principles are design principles that include questions like

  • Is the component scalable?
  • How is DI managed?
  • What are patterns used for the prevention of complexity?
  • How common components are sharing code, and in which manner?

Modularization is a technique where we separate the layers/features into modules. By using multi-module architecture, complex projects can easily be into more readable, testable, reusable, and scalable forms. And improve the Gradle build performance.

Both clean architecture and modularization help the new team members to understand the flow of code easily. It makes adopting processes smooth and easy.

As Android apps grow in size, it’s important to design architecture that allows the app to scale, increases the app’s robustness, and makes the app easier to test. Here are some design principles we can follow to design our app:

Separation of logic

It’s a common mistake to write all your code inside an activity or fragment. We should try to separate some code into other classes to make them reusable and readable. But it doesn’t mean we must put all the code into other classes. While separating the code, you should be very careful while passing context, proper registering or unregistering callbacks, and avoid separating activity/fragment-specific APIs-related code.

And also, try to use Kotlin design patterns like Factory, Builder, etc. If you are thinking about Singleton pattern, yes, you can use this as well. But it comes with the cost of complex testing, thread safety, etc.

This is one of the most important designs we should follow. From a clean perspective, we should separate the network and local data models and keep it like one for network and another one for local. This saves you from the overburden of unnecessary data you will present in your UI. Rather than sharing data models direct to UI from ViewModelwe should try to share only that state which holds only UI-specific data with the initial value.

In general, we put validation in activity, fragmentand Viewmodel, But in reality, This is very bad practice. To solve this, we should make a validation helper or usecase class that will validate the inputs. Also, we should try to do mapping only in a repository, not in viewmodel or activity/fragment.

When a new data type is defined in your app, you should assign a Single Source of Truth (SSOT) to it. The SSOT is the owner of that data, and only the SSOT can modify or mutate it. To achieve this, the SSOT exposes the data using an immutable type, and to modify the data, the SSOT exposes functions or receives events that other types can call.

Generally, in apps, we’ve database and network sources of data. To implement the SSOT, we fetch the network data and insert it into a database that is not directly accessible to UI. To access the data, UI will use only the database.

While defining a new data type, you should not expose network data directly to UI. While making an API call, you should only expose the UI state or UI-related data model to the Presentation layer. By adding immutability, it makes UI only focus on a single role — reading and displaying the data.

As a result, you should never modify the UI state in the UI directly unless the UI itself is the sole source of its data. The most common mistake we make is by sharing mutable data to UI or passing data from UI to ViewModel directly via params.

Let’s practice the clean and scalable architecture that includes modularization. First, let me share some tips regarding making things clean in architecture:

  1. buildSrc: This is a Kotlin-DSL-based module that provides external support to Kotlin’s Gradle scripts. In buildSrcwe define properties outside the Gradle scripts and later, use them into Gradle scripts without getting into messy junk.

This is how build.gradle.kts look like when we use buildSrc:

For the implementation of buildSrcyou can simply create a directory named buildSrc. Inside buildSrccreate src/main/java dir. Also, create build.gradle.kts and add kotlin-dsl as a plugin like the following:

And create a file named Libs.kt.

Kotlin is a powerful language with many powers like higher-order functions, smart cast, inline or infix functions, etc. Extensions functions make code cleaner and more readable.

Builder pattern

The builder pattern is one of the most beautiful patterns. You must follow design patterns for clean architecture.

Usecases are the best ways to convert complex logic into simpler and reusable code. In general, usecase defines a single data operation. It doesn’t have its own lifecycle. It means you can call them anywhere in your application.

Use cases from the domain layer must be main-safe; In other words, they must be safe to call from the main thread. If use case classes perform long-running blocking operations, they are responsible for moving that logic to the appropriate thread.

Before diving into a project structure, let’s first understand the layers of architecture. We generally have three layers: Data, Domain, and Presentation.

The data layer is a part of the business logic. It includes local storage or caching, networking, models and mappers, etc. It manages data operations like fetching data from the network or local database.

It’s a good practice if we define separate data sources for the network and database. Often, we get into the mess of merging network and database data sources. We should define separate data sources like the following:

Also, when it comes to business models, we should separate these models by network and local database cases like the following:

Benefits

  1. Prevent nullable data
  2. Easy mapping
  3. Reduce the complexity of annotations.
  4. Sometimes, our screens don’t need the full data from network models. In this case, we can provide only needed data to UI.

While making an API call or any local database operation, it should be safe to main-thread. It’s a good practice to inject CoroutineDispatcher using DI.

Sometimes, we need an in-memory cache to preserve the data. Suppose a new requirement is introduced for the News app: when the user opens the screen, cached news must be presented to the user if a request has been made previously. Otherwise, the app should request a network to fetch the latest news.

You can preserve data while the user is in your app by adding in-memory data caching. Caches are meant to save some information in memory for a specific time — in this case, as long as the user is in the app. Using Mutex from Kotlin Coroutines, we can lock the thread-safe write.

Suppose the user navigates away from the screen while the network request is in progress, it’ll be canceled, and the result won’t be cached. In this case, you can make an APIs call inside some external coroutine scope.

The domain layer is responsible for encapsulating the complex business logic. It connects UI and Data layer using a reusable usecase or interactor that can easily reuse by multiple data providers like the viewmodel. Usecase is responsible for single data operations like fetching data from users, inserting data into local databases, validating input types, etc.

You can also use some base interactor or usecase class like the following:

You can use Interactor as a base class, as shown below:

The presentation layer is also known as UI Layer. It is responsible for displaying application data on the screen. In simple words, it deals with the UI part of an application. In Presentation layer, UI triggers the event to viewmodel or other source or vice versa.

Here are some principles you should know:

UI state

It’s a bad practice to expose the data directly from viewmodel or another source to UI Layer. Rather than having multiple states or properties for updating the UIyou should consider making a single State Model that holds only required UI Data. The key benefit of this is UI able to focus on only one role of reading the data, not writing or mapping the data.

As a result, you should never modify the UI state in the UI directly unless the UI itself is the sole source of its data. Only the source or owner of data should be allowed to update the data, not the UI directly.

Exposing data

You should consider the immutability of the UI state because exposing the right to update/write data directly to UI is not a good idea. The key to this principle is that exposing data is only allowed to its owner, not UI.

Here is an example of using state, combining the flows, managing the UI messages, etc.

In an ever-growing code base, scalability, readability, testability, and overall code quality often decrease over time. This comes as a result of the codebase increasing in size without its maintainers taking active measures to enforce an easily maintainable structure. Modularization is a means of structuring your codebase in a way that improves maintainability and helps avoid these problems.

Modularization is a practice of organizing a code base into loosely coupled and self-contained parts. Each part is a module. Each module is independent and serves a clear purpose.

In Modularization, it depends on you to divide your app into different modules by the feature or by the architecture layers. At some points, you might be confused about choosing module separation. You should separate it by features with architecture layers. But each feature module should not depend on other feature modules. You should keep it independent as much as you can. It improves the reusability of modules.

In Modularization, each feature module cannot communicate directly with other feature modules. In fact, the App module has the access to all modules of the app.

There are some practises, ideas, and solutions you can follow to create a scalable architecture with modularization.

  1. Managing database: You might think, can we have multiple databases according to app features? Or should we only hold one database for an app? As a solution to this, you can adopt both approaches. It depends on your app needs. You should not go for the multiple databases approach if your app needs database tables join-related data. If your app doesn’t have to deal with multiple database’s table joins, you can adopt the multiple databases approach easily.
  2. App navigation: While dealing with app navigation, you should hold a central place where all your navigation screens are combined to make awesome app navigation. You can choose App Module as the central place and easily manage your app navigation into this.

Good structure is everything

While adopting a clean architecture, you should divide your app into multiple layers to make a good project structure.

Without wasting your time, let’s directly jump into the project structure.

I know you are also looking for a project structure for Kotlin Multiplatform Mobile. Don’t worry. I also have a good project structure for it. You can make small changes to previous project modules and convert them into KMM structure. You can check out my KMM project for reference.

Leave a Comment