When it comes to Android architecture, there are a few best practices you can’t do anything wrong with. What does it even mean for your architecture to be more scalable? Think about creating insurance for the application you are working on to handle feature growth and maintenance effectively. Thus, an app with a scalable architecture means the following:
- Your codebase is easier to work on.
- It is easier to extend your codebase with a new feature.
- It is easier to modify existing features.
- It is easier for developers to understand what your code does – this is especially important for new developers!
Practice #1: Dependency Injection
Dependency Injection is a design pattern that I would always apply; it is so beneficial. Say we have the following AuthRepository and its implementation:
interface AuthRepository {
fun doSomething()
}
class KtorAuthRepository : AuthRepository {
override fun doSomething() {
// perform an operation
}
}
We now want to inject that into one of our ViewModels using DI. The idea behind the DI pattern is to inject these dependencies from the outside and not construct them inside the class, as shown below. Doing so makes our codebase unscalable because all our dependencies are scattered throughout our project. You will eventually have multiple instances of the same dependency you might need globally, leading to hard-to-find bugs. Having a central place where all your dependencies are managed makes much more sense.
class LoginViewModel : ViewModel() {
// WRONG - initializing dependency inside
private val repository = KtorAuthRepository()
}
class LoginViewModel(
// CORRECT - dependency is passed to the ViewModel
private val repository: AuthRepository
) : ViewModel() { }
Declare your dependencies inside the constructor and, very importantly – have it depend on the abstraction. Here is where the power of the DI pattern shines: The class that initializes this ViewModel can decide what specific type of repository it wants to use. You can use the KtorAuthRepository
class to create your production code and for your unit tests, you can provide a more lightweight implementation with some test data to ensure that if any tests fail, it is indeed a bug in your ViewModel.
Another benefit is that if your implementation depends on a specific HTTP library, you can easily swap it out for a different implementation that depends on another one. Our ViewModel would remain unchanged since it does not know the specific implementation to fulfill the interface’s functions.
I’ve shown you an example of manual DI, but there are also some well-established DI frameworks like Dagger, Hilt, and Koin that you can use if any of them fit the needs of your project.
For more on DI, you can watch my video on the topic The Ultimate Dagger-Hilt Guide (Dependency Injection) – Android Studio Tutorial
Practice #2 Testing
A well-tested codebase gives you a lot of confidence in your code. You’ll also be less afraid of breaking something when you know that you must run your tests to see if everything is working correctly.
There is a time investment when creating tests, but it can save you a lot of time in the long run. This is not to say you won’t need to perform manual tests, but at least 60-80% of your testing efforts can be delegated to automated tests. This automation will make you more likely to find bugs, which not only ensures your app is more robust but will make app releases faster since, with fewer bugs, your QA in your development team won’t have to hand the app back to you with bugs they found during their tests.
You can learn more about testing in my course, Mastering Automated Testing of Industry-Level Android Apps
Practice #3 Modular Design
To best communicate the benefits of a modular design of your codebase, I’ll compare it to Lego bricks. Each Lego brick is an individual piece that can be combined in various ways with other Lego pieces. The fun of playing with Lego came from the ability to use your imagination to build anything with the pieces that you have. This was possible because the pieces were small enough for such flexibility. If the opposite were true and a Lego set to build the Millennium Falcon (from the Star Wars franchise) only included four big pieces, that would be pretty boring. You wouldn’t be able to use those pieces to build anything else.
The same is true for your code. At the lowest level, a function that adheres to the Single Responsibility Protocol is much easier to reuse in other parts of your code. If we zoom out a bit, we see our class that we can also make modular. I have a class I made in my course Building Industry-Level Wear OS Apps called WearMessagingClient
.This class is modular because it can be reused anywhere where I need to communicate with a Wear device, but nothing more than that since it only handles communication with a Wear device and not, for example, handle logic to scan for devices; that responsibility can be a class of its own.
Single responsibility is a crucial marker for a modular project. Keep the following in mind as you code: It does not matter if you are coding a function, a class, or creating packages or Gradle modules—you can keep your design modular. With the package and Gradle module structure, give them names that describe what they are responsible for—this way, if you need that responsibility anywhere else in your project, you can reuse it.
Practice #4 Avoid In-Memory Global State
Nothing harms scalability more than weird errors that keep popping out of nowhere, requiring you to first focus on locating and fixing it (and try not to implement hacky workarounds!). A common source of these errors is in-memory global states. Let’s take a look at an example, which is a class responsible for storing a session token:
class SessionManager {
private val _accessToken = MutableStateFlow<String?>(null)
val accessToken = _accessToken.asStateFlow()
}
This code is especially problematic in the Android environment because of process death. This is where the Android system kills your app’s process when it needs to free up some memory for other tasks. The catch is that the Android system automatically tries to restore your app’s backstack when the user returns the app from the background. However, the memory was still cleared, so our session token was also cleared and reset to the default value. This can cause your user to be on a screen that requires a valid token, but now the app no longer has the token – not an ideal state for your app.
When we have a global state like a session token, the most secure place to store it is persistent storage, such as SharedPreferences. Doing so lets you observe or access the state from there, protecting it from process death and avoiding hard-to-find bugs.
Practice #5 Package Structure Consistency
Let’s be clear, there is no single approach to package structuring that is simply the best—there are many that work and many that are objectively bad. A common element of bad package structure is inconsistency.
Inconsistency in a project causes confusion among developers. Take, for example, a package that contains DI-related code; in parts of the code, it is named di, and in others, it is named dagger. This is something small, but the more inconsistencies in your code, the greater the confusion it will cause, and the more inconsistencies will appear in your project.
Having this consistency also frees you up to contribute more efficiently to a project, as you won’t have to overthink about what to name a package or where to place a specific class. Avoid a bad package structure by making yours at least consistent.
Master Building Large-Scale Native Android Apps
Take this 23h online course to learn everything it takes to become an industry-ready native Android developer. Learn about multi-module architecture, Gradle, Jetpack Compose, authentication, offline-first development and more.