Designing a scalable architecture for multi-module Jetpack Compose apps
Jetpack Compose, navigation, multi-module architecture, API and Impl modules, dependency injection — in this blog post, we will see how these components can be combined into a scalable multi-module architecture using best practices in Android development. We will also see how to use a Compose Navigation API to efficiently navigate through features in multi-module apps.
When we are talking about modules or multi-module architecture, we consider mostly Gradle modules.
Long story short. Each feature of the application could be represented by the diagram below and consists of API and implementation modules. API part holds a public interface of the feature including an abstract @Composable
entry point to it and a DI dependency provider. All implementation details of the feature are stored in the Impl module.
The overall structure of a single feature
In this blog post, we will go through each component of this diagram including code implementations, so that you have a clear understanding of how it works.
We will try all the concepts in practice on a sample Android application, the source code of which could be found on GitHub.
This project is also buildable with Bazel build system. Use the
//app:bin
target to build and run the application with Bazel.
Multi-module architecture
Alright. You saw the title. We are talking not about just any Jetpack Compose app but the multi-module ones. But what makes multi-module projects so different? Well, they are definitely more complex architecture-wise. And most importantly, they are very easy to mess up, so you no longer have the benefits they bring, but still, you have to deal with the increased complexity.
However, one of the main goals of multi-module architectures is separation. Separation of many aspects. A logical separation into feature modules, for easier development and maintenance of the codebase. And a separation for the sake of efficient builds.
Latter becomes more and more important as the project grows. This way instead of building the entire monolith each time, the build system will incrementally rebuild only modules that were changed. And those that depend on them, of course.
So, to make the multi-module architecture efficient and scalable it is important to properly define the rules by which modules would depend on each other.
How do we do it? First of all, we can logically split all the modules in our project into 3 categories such as feature modules, library modules, injector modules.
Let’s start with the most interesting part. Feature modules. These represent some specific components of the application that can be considered as separate features. While working with features we must ensure the following:
- Each feature module in the project should directly depend on as few modules as possible.
- Each module that our feature depends on should be as small as possible.
To achieve this we will split each feature into 2 modules, API and implementation (Impl). The API module represents a public interface/facade of the feature. It contains only a set of Java/Kotlin interfaces that define an entry point to the feature. It also includes entities that it provides to its consumers through dependency injection. This module must be as small as possible.
The Impl module on the other hand contains all the implementation details of the feature. Moreover, if any other module wants to use the feature, it can only depend on its API module but not the implementation.
Separation of a feature into API and Impl modules
Another type of module is library modules. These are just simple modules that hold some common logic that is used by other features. Nothing fancy here.
Finally, we have an injector module. This is the root module that stands as an entry point to the application. Its main purpose is to set up a dependency graph for the entire application. That is why it is called an “injector”. When the application launches, such modules do their job and then delegate execution to specific feature modules. An injector module depends on all other project modules.
As an example, this can be a usual app module that you have in your Android project.
Combining all 3 types of modules in the project allows us to see the multi-module project structure on the diagram below.
Multi-module project architecture. (Dependencies between features and libraries on the diagram are random)
Concrete example project
To make the demonstration a little bit easier we will use a concrete example project that is built on concepts discussed in the blog post.
Concrete example project with a multi-module architecture
Our sample project will display the list of images uploaded by different users. Therefore, it consists of 3 main features. The images feature implements feed functionality to display the content to the user. The profile feature shows the details about specific users and the images they uploaded. Finally, the data feature represents a data layer of the application as per clean architecture and is responsible for providing content for other features.
All the features depend on the common library module that holds some general code of the project. We will see it in more detail later in this blog post.
Photo by Radik Sitdikov on Unsplash
Navigation
Now, let’s talk about navigation. First, let’s see what do we have available in the standard Jetpack Compose Navigation API. The code snippet below shows how to set up the navigation for Jetpack Compose in the project.
Jetpack Compose Navigation API
As you can see, we define the route name as a string in the composable
function call for each destination in the app. When the user moves by this route, the corresponding composable function is going to be executed.
Also, it is possible to pass arguments during the navigation.
Jetpack Compose Navigation API. Passing arguments
Now the route string contains an argument name in curly braces. In addition, we specify names and types of the arguments that could be passed to the enclosing composable function. All the argument values are stored in a Bundle
with name arguments
from which they can be retrieved.
If we want to navigate to the specific route we need to use a string with actual argument values without curly braces.
Navigating to the specific destination
As you can see, we can have 2 types of navigation strings. The first one is a route that defines a template (or signature) for navigation. The second one is a destination that specifies the navigation to the particular composable with specific arguments passed.
You can learn more about Jetpack Navigation API from the official Android documentation.
We will use this API for our purposes. However, additionally, we will adapt it a bit for our multi-module architecture.
Since we use Jetpack Compose, we need to have an entry point to the feature which is represented by a @Composable
function. However, don’t forget that as per our multi-module architecture, each feature is separated into API and Impl components.
Therefore, we need to have an interface of a feature entry component declared in the API module and its implementation in a separate module.
As you can see, we can have 2 types of navigation strings. The first one is a route that defines a template (or signature) for navigation. The second one is a destination that specifies the navigation to the particular composable with specific arguments passed.
You can learn more about Jetpack Navigation API from the official Android documentation.
We will use this API for our purposes. However, additionally, we will adapt it a bit for our multi-module architecture.
Since we use Jetpack Compose, we need to have an entry point to the feature which is represented by a @Composable
function. However, don’t forget that as per our multi-module architecture, each feature is separated into API and Impl components.
Therefore, we need to have an interface of a feature entry component declared in the API module and its implementation in a separate module.
API and Impl parts of a composable feature entry
Now, let’s see how to implement this in code. All the entry points to features would implement a base FeatureEntry
interface.
import androidx.navigation.compose.composable | |
interface FeatureEntry { | |
val featureRoute: String | |
fun NavGraphBuilder.composable( | |
navController: NavHostController, | |
destinations: Destinations, | |
) { | |
composable(featureRoute) { backStackEntry -> | |
Composable(navController, destinations, backStackEntry) | |
} | |
} | |
... | |
} |
Feature entry interface code. Part 1
First, it has a featureRoute
property that defines the route to the feature. Then, it contains a composable
function. If you remember from the Compose Navigation API, you call a similar composable
function inside NavHost
in order to define variable routes for your navigation graph.
Jetpack Compose Navigation API. Calling composable function inside NavHost
In our approach, we will use a composable
function from FeatureEntry
instead in NavHost
. You will see how it works shortly.
Inside, it calls another function called Composable
(first letter capital).
interface FeatureEntry { | |
val featureRoute: String | |
... | |
@Composable | |
fun Composable( | |
navController: NavHostController, | |
destinations: Destinations, | |
backStackEntry: NavBackStackEntry | |
) | |
} |
Feature entry interface code. Part 2
This is actually a function marked with @Composable
annotation and its implementation would stand as an entry point to the UI of the feature.
It takes an argument of Destinations
type.
typealias Destinations = Map<Class<out FeatureEntry>, @JvmSuppressWildcards FeatureEntry> | |
inline fun <reified T : FeatureEntry> Destinations.find(): T = | |
this[T::class.java] as T |
Destinations code
Destinations
is just a type alias for a Map
. Therefore, all feature entries of the project would be stored in such a data structure. It is used to refer to API parts of other features and navigate to them.
@JvmSupressWildcards
annotation is added as a workaround for Dagger here. Don’t worry much about it. It will allow us to inject our FeatureEntry
objects using @IntoMap
later in the blog post.
There are also a couple of extension functions to Destinations
such as find
that help to retrieve the right routes for the navigation as shown below.
Retrieving feature entry point
Now, let’s see an example of how FeatureEntry
can be used. In our sample application, we have an images feature that can show a feed of images to the user.
First, we need to define an interface of the feature in its API module. We will create ImagesEntry
abstract class that serves as an entry point to the feature.
abstract class MovieSearchEntry : FeatureEntry { | |
final override val featureRoute = "movie-search" | |
} |
In the Impl module, we will create ImagesEntryImpl
class that implements ImagesEntry
. Its Composable
function serves as an implementation of a feature entry point. It would contain calls to composable functions and a dependency injection setup.
class MovieSearchEntryImpl @Inject constructor() : MovieSearchEntry() { | |
@Composable | |
override fun Composable( | |
navController: NavHostController, | |
destinations: Destinations, | |
backStackEntry: NavBackStackEntry | |
) { | |
// Build your feature UI here. | |
... | |
} | |
} |
In order to add our feature entry to the navigation graph, we can use the following code.
Building a navigation graph based on feature entries
Instead of using composable
function from the Navigation API, we are using one that we earlier defined in FeatureEntry
. In order to do this, we wrap the call with a with
function from Kotlin standard library. This way we can enter the context of ImagesEntry
and make the right function call.
Nested navigation
Jetpack Compose Navigation API also allows defining nested navigation graphs.
Nested navigation API in Compose
Nested graphs can be created using navigation
function. They in turn can contain various routes defined with composable
function.
Now, we will see, how nested navigation graphs can be used in our multi-module architecture. In order to do this, we will need to bring some changes to the FeatureEntry
interface that we’ve defined earlier.
Now, FeatureEntry
would contain only a featureRoute
property. In addition, it will have arguments
property that would allow us to define argument types with Navigation API.
However, we would also need to define two successors of FeatureEntry
.
- One of them,
ComposableFeatureEntry
would serve as a base simple case and would behave exactly the same asFeatureEntry
was before.
Now,
ImagesEntry
that we defined earlier, would directly extendComposableFeatureEntry
instead ofFeatureEntry
.
- Another one,
AggregateFeatureEntry
would cover cases when a feature implements nested navigation and contains its own navigation subgraph. It contains anavigation
function that would allow a feature entry to define a navigation subgraph in it.
import androidx.navigation.compose.NamedNavArgument | |
interface FeatureEntry { | |
val featureRoute: String | |
val arguments: List<NamedNavArgument> | |
get() = emptyList() | |
} |
import androidx.navigation.NavHostController | |
import androidx.navigation.NavBackStackEntry | |
interface ComposableFeatureEntry : FeatureEntry { | |
fun NavGraphBuilder.composable( | |
navController: NavHostController, | |
destinations: Destinations, | |
) { backStackEntry -> | |
composable(featureRoute, args) { | |
Composable(navController, destinations, backStackEntry) | |
} | |
} | |
@Composable | |
fun Composable( | |
navController: NavHostController, | |
destinations: Destinations, | |
backStackEntry: NavBackStackEntry | |
) | |
} |
import androidx.navigation.NavHostController | |
interface AggregateFeatureEntry : FeatureEntry { | |
fun NavGraphBuilder.navigation( | |
navController: NavHostController, | |
destinations: Destinations, | |
) | |
} |
FeatureEntry base interfaces after refactoring
Job Offers
Let’s add an entry to another feature from our sample application. This time it will be profile. For this feature, we will define a navigation subgraph with 2 destinations: user profile and settings screens.
First, we define a ProfileEntry
abstract class in the API module of the feature. This time it will implement an AggregateFeatureEntry
that we created earlier.
import androidx.navigation.NavType | |
import androidx.navigation.compose.navArgument | |
abstract class MovieDetailsEntry : AggregateFeatureEntry { | |
final override val featureRoute = "movie-details?movieId={movieId}" | |
final override val arguments = listOf( | |
navArgument("movieId") { | |
type = NavType.IntType | |
} | |
) | |
fun destination(movieId: Int): String = | |
"movie-details?movieId=$movieId" | |
} |
API part of profile feature entry
We define 2 destinations for this feature: userProfileDestination
and myProfileDestination
. Former will navigate to a specific user’s profile by its id while latter will show a profile page of a current user.
In the Impl module, we need to create ProfileEntryImpl
in which we implement navigation
function that stands as an entry point to the feature. Inside, we can define a navigation subgraph as it can be usually done with Compose Navigation API.
import androidx.navigation.compose.composable | |
import androidx.navigation.navigation | |
class MovieDetailsEntryImpl @Inject constructor() : MovieDetailsEntry() { | |
override fun NavGraphBuilder.navigation( | |
navController: NavHostController, | |
destinations: Destinations | |
) { | |
navigation(startDestination = destination(), route = "@ignored") { | |
composable(route = featureRoute, arguments) { ... } | |
composable(route = "credits/{movieId}") { ... } | |
} | |
} | |
} |
Implementation of a profile feature entry point
Our subgraph will have two routes: user profile and settings. The route for the former is taken from featureRoute
property defined in ProfileEntry
. It also accepts arguments, and therefore, we pass arguments
definitions that are also defined in ProfileEntry
.
The snippet below shows how to navigate to this feature.
Navigating to the profile feature
Features without UI
It is also possible to have features without any UI. They are still separated into API and Impl modules. However, they need to use neither Jetpack Compose nor navigation.
This is how the data feature from our sample project looks like. Its main goal is to provide the data for the application, so no UI is required.
Such features, however, have one thing in common with UI features. It is a dependency injection setup that we will see next.
Photo by Robin van Holst on Unsplash
Dependency injection
We will use Dagger for dependency injection. Why not Hilt? Since we are considering a multi-module architecture, each module most likely would have defined Dagger components. This means that every time we define dependencies between modules, their components would also need to depend on each other somehow. Let’s see two ways how a Dagger component can depend on other components:
- Subcomponents — where the parent component specifies its children.
- Component dependencies — where the child component specifies which components it depends on.
Hilt uses subcomponents to set up dependencies between components. As we know Hilt is based on Dagger. We also know that Dagger heavily relies on code generation so that for each defined component interface there will be generated an implementation code.
The problem with subcomponents lies in the fact that Dagger generates the implementation code for each of them in the parent component class. This can be especially a problem in large multi-module applications. If you have a project that consists of hundreds of modules and use subcomponents, most of the generated code for Dagger components would be generated in a single file of a root/parent component. This file would consist of tens of thousands of lines of code which noticeably slows down the compilation/build speed of the project.
To be fair, there are planned improvements of the subcomponents mechanism in this regard in Dagger but we will use the component dependencies mechanism as of now.
Alright. Then decided. We are using component dependencies. The code snippet below shows how to declare a component dependency in Dagger.
@Component | |
interface ComponentA { | |
// provided dependencies | |
} |
@Component(dependencies = [ComponentA::class]) | |
interface ComponentB { | |
... | |
} |
Dagger component dependencies mechanism
Basically, in the dependencies
argument of @Component
annotation we specify the list of component interfaces we would like to depend on.
However, Dagger has one more interesting feature in this regard. It is possible to depend not only on interfaces annotated with @Component
but on regular plain Kotlin/Java interfaces. Let’s take a look at the code snippet below.
interface ProviderA { | |
// provided dependencies | |
} | |
@Component | |
interface ComponentA : ProviderA { | |
... | |
} |
@Component(dependencies = [ProviderA::class] | |
interface ComponentB { | |
... | |
} |
Dagger component dependencies on plain interfaces
As you can see ProviderA
is a regular interface without any Dagger annotations. In this interface, you can declare what dependencies would be provided by ComponentA
that implements it. As a result, ComponentB
can depend directly on ProviderA
interface.
Why is this important for us? As you remember, we split out modules into API and Impl parts. API part does not have access to the implementation classes from Impl and thus they can’t be instantiated with dependency injection this way. Therefore, DI also should be split across API and Impl modules.
Dagger component that extends provider interface from the API module
API part will contain Provider
interface that would define the list of dependencies that this feature provides to others. Any Dagger component from other features would depend on this interface through the component dependency mechanism. This way API modules of any feature need to know nothing about Dagger.
The below snippet shows Provider
interface for a data feature from the sample application.
interface DataProvider { | |
val moviesRepository: MoviesRepository | |
} |
Example of a dependency provider in API module
Impl part, in turn, contains your regular feature scoped Dagger component but also extends DataProvider
interface.
@Singleton | |
@Component( | |
modules = [DataModule::class] | |
) | |
interface DataComponent : DataProvider |
Dagger component in Impl module
There can be created any Dagger structure inside Impl module as long as it is isolated. Components can depend on other components or even have subcomponents. All of this would be implementation details while all other features would have access only to the Provider
interface from the API module.
Now, let’s see how to inject dependencies into features. Let’s do it for ImagesEntryImpl
that we defined earlier.
Since an entry point to our feature is @Composable
, all we need inside this function is a ViewModel so that all the logic will be delegated to it.
class MovieSearchEntryImpl @Inject constructor() : MovieSearchEntry() { | |
@Composable | |
override fun Composable(...) { | |
val dataProvider = LocalDataProvider.current | |
val viewModel = injectedViewModel { | |
DaggerMovieSearchComponent.builder() | |
.dataProvider(dataProvider) | |
.build() | |
.viewModel | |
} | |
MovieSearchScreen(viewModel, ...) | |
} | |
} |
import androidx.lifecycle.ViewModel | |
class MovieSearchViewModel @Inject constructor(...) : ViewModel() { ... } |
@Composable | |
fun MovieSearchScreen(viewModel: MovieSearchViewModel, ...) { ... } |
Injecting dependencies into feature entry
In order to do the injection in a composable world, we can create an injectedViewModel
function that helps to provide the right instance of a ViewModel in the @Composable
function.
@Composable | |
inline fun <reified VM : ViewModel> injectedViewModel( | |
key: String? = null, | |
crossinline viewModelInstanceCreator: () -> VM | |
): VM { | |
val factory = remember(key) { | |
object : ViewModelProvider.Factory { | |
override fun <VM : ViewModel> create(modelClass: Class<VM>): VM { | |
@Suppress("UNCHECKED_CAST") | |
return viewModelInstanceCreator() as VM | |
} | |
} | |
} | |
return viewModel(key = key, factory = factory) | |
} |
Body of injectedViewModel function
One more thing to consider is component dependencies. As we can see in ImagesEntryImpl
code snippet above, DaggerImagesComponent
depends on DataProvider
that is part of the API module of a data feature and has a wider Dagger scope.
In order to retrieve an instance of DataProvider
we can use the composition locals mechanism from Jetpack Compose.
interface DataProvider { ... } | |
val LocalDataProvider = compositionLocalOf<DataProvider> { | |
error("No data provider found!") | |
} |
val dataProvider = LocalDataProvider.current | |
val viewModel = injectedViewModel { | |
DaggerMovieSearchComponent.builder() | |
.dataProvider(dataProvider) | |
.build() | |
.viewModel | |
} |
Using composition locals for component dependencies mechanism
Learn more about composition locals from the official documentation.
We define a LocalDataProvider
in the same file with DataProvider
. When we build the UI we need to provide an instance of a DataProvider
with a CompositionLocalProvider
function. This way it will be available down through the composition.
Since
DataProvider
is an interface which is extended by aDataComponent
, its instance can be created the way you usually create Dagger components:DaggerDataComponent.Builder().build()
. You can checkSampleApplication
class in the app module of a sample application for more details.
There is one more class type that requires a special approach for dependency injection.
We have already discussed feature entry entities earlier in this blog post. Regardless of the scopes of Dagger components of features, their entries must be singletons, so that they are available every time the feature is entered.
This means they should be defined and provided in separate Dagger modules inside features.
Dagger module that provides a feature entry
Let’s see how this is done for the images feature.
We need to define a separate Dagger module that would add an instance of a feature entry into the map.
@Module | |
interface MovieSearchEntryModule { | |
@Binds | |
@Singleton | |
@IntoMap | |
@FeatureEntryKey(MovieSearchEntry::class) | |
fun movieSearchEntry(impl: MovieSearchEntryImpl): FeatureEntry | |
} |
Dagger module for a feature entry
We also use custom Dagger @MapKey
in order to restrict map key type to FeatureEntry
instances.
import dagger.MapKey | |
@MapKey | |
annotation class FeatureEntryKey(val value: KClass<out FeatureEntry>) |
Custom Dagger map key for feature entries
Finally, in the app (injector) module, we gather all the feature entry Dagger modules in a NavigationModule
which then is added to the AppComponent
.
@Singleton | |
@Component( | |
modules = [NavigationModule::class] | |
) | |
interface AppComponent { | |
val destinations: Destinations | |
} |
Gathering all feature entries in the root Dagger component
As a result, all the FeatureEntry
instances are gathered in a destinations
Map
. It is available from the AppComponent
which is assumed to be the root Dagger component in the app.
As you remember,
Destinations
is a type alias forMap
that we’ve defined earlier.
Very often, you might need to inject an application Context
into your features. The most simple way to do this is to bind its instance to the dependency graph in the AppComponent
. This way, Dagger components from other features would depend on it and get access to the Context
.
@Singleton | |
@Component | |
interface AppComponent { | |
val appContext: Context | |
@Component.Factory | |
interface Factory { | |
fun create(@BindsInstance appContext: Context): AppComponent | |
} | |
} |
Root Dagger component that provides application context
However, there is a catch here. In our multi-module architecture app (injector) module depends on all other feature modules but not vice versa. This means that feature modules do not have access to AppComponent
.
Feature component is not able to directly depend on app component
This can be solved if we move a Dagger component that provides application Context
to a library module. Let’s call it common.
Depending on singleton dependencies provided by the app component
The instance of an application Context
can be provided the same way through CommonComponent
in the library module.
interface CommonProvider { | |
val appContext: Context | |
} |
@Singleton | |
@Component | |
interface CommonComponent : CommonProvider { | |
@Component.Factory | |
interface Factory { | |
fun create(@BindsInstance appContext: Context): CommonComponent | |
} | |
} |
Defining a Dagger component for common dependencies in the project
The AppComponent
in the app (injector) module in turn will add CommonComponent
as a dependency through its provider.
@Singleton | |
@Component( | |
dependencies = [CommonProvider::class], | |
modules = [NavigationModule::class] | |
) | |
interface AppComponent : CommonProvider |
Finally, if we need access to Context
in any feature, we add CommonProvider
as a dependency to its Dagger component.
@Component( | |
dependencies = [CommonProvider::class], | |
modules = [DataModule::class] | |
) | |
interface DataComponent : DataProvider |
Adding common Dagger component to the feature
Similarly, CommonProvider
can be used to provide any type of object that is common to the entire application.
Conclusion
We have just seen an approach to designing a scalable multi-module architecture for Android apps that use Jetpack Compose, including navigation. Below is the complete diagram that defines a structure of a single feature in the app using the described approach.
The complete structure of a single feature
The source code with an example project can be found on GitHub by the link below.
Feel free to share your thoughts or concerns in the comments section.
Thanks to Omolara Adejuwon.