Blog Infos
Author
Published
Topics
,
Published
Designing a scalable architecture for multi-module Jetpack Compose apps

The overall structure of a single feature

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.

  • Each module that our feature depends on should be as small as possible.

Separation of a feature into API and Impl modules

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

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.

 

 

val navController = rememberNavController()
NavHost(navController = navController, startDestination = "images") {
composable("images") { Images(...) }
composable("profile") { Profile(...) }
...
}

Jetpack Compose Navigation API

 

NavHost(startDestination = "profile/{userId}") {
...
composable(
"profile/{userId}",
arguments = listOf(navArgument("userId") { type = NavType.StringType })
) {
Profile(backStackEntry.arguments?.getString("userId"), ...)
}
}

Jetpack Compose Navigation API. Passing arguments

 

navController.navigate("profile/user123")

Navigating to the specific destination

 

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

 

val navController = rememberNavController()
NavHost(navController = navController, startDestination = "images") {
composable("images") { Images(...) }
composable("profile") { Profile(...) }
...
}

Jetpack Compose Navigation API. Calling composable function inside NavHost

interface FeatureEntry {
val featureRoute: String
...
@Composable
fun NavGraphBuilder.Composable(
navController: NavHostController,
destinations: Destinations,
backStackEntry: NavBackStackEntry
)
}

Feature entry interface code. Part 2

 

typealias Destinations = Map<Class<out FeatureEntry>, @JvmSuppressWildcards FeatureEntry>
inline fun <reified T : FeatureEntry> Destinations.find(): T =
this[T::class.java] as T

Destinations code

 

val myFeature = destinations.find<MyFeatureEntry>()

Retrieving feature entry point

 

abstract class ImagesEntry : FeatureEntry {
final override val featureRoute = "images"
}

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 ImagesEntryImpl @Inject constructor() : ImagesEntry() {
@Composable
override fun NavGraphBuilder.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

 

Nested navigation

Jetpack Compose Navigation API also allows defining nested navigation graphs.

NavHost(navController, startDestination = startRoute) {
...
navigation(startDestination = nestedStartRoute, route = nested) {
composable(nestedStartRoute) { ... }
}
...
}

Nested navigation API in Compose

 

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 NavGraphBuilder.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

Job Offers


    Lead Android Engineer

    ASOS
    London
    • Full Time
    apply now

    API Engineer

    American Express
    New York, USA
    • Full Time
    apply now

    Android Entwicklerin Echtzeitkommunikation

    sipgate GmbH
    Germany
    • Full Time
    apply now
Load more listings

OUR VIDEO RECOMMENDATION

Jobs

import androidx.navigation.NavType
import androidx.navigation.compose.navArgument
abstract class ProfileEntry : AggregateFeatureEntry {
final override val featureRoute = "profile?userId={userId}"
final override val arguments = listOf(
navArgument("userId") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
fun userProfileDestination(userId: String): String =
"profile?userId=$userId"
fun myProfileDestination(): String =
"profile"
}

API part of profile feature entry

 

import androidx.navigation.compose.composable
import androidx.navigation.navigation
class ProfileEntryImpl @Inject constructor() : ProfileEntry() {
override fun NavGraphBuilder.navigation(
navController: NavHostController,
destinations: Destinations
) {
navigation(startDestination = myProfileDestination(), route = "@profile") {
composable(route = featureRoute, arguments) { ... }
composable(route = "settings") { ... }
}
}
}

Implementation of a profile feature entry point

 

val destinations: Destinations = ...
val profileDestination = destinations
.find<ProfileEntry>()
.userProfileDestination(userId)
navController.navigate(profileDestination)
view raw Navigation.kt hosted with ❤ by GitHub

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.

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:

  • Component dependencies — where the child component specifies which components it depends on.
@Component
interface ComponentA {
// provided dependencies
}
view raw ComponentA.kt hosted with ❤ by GitHub
@Component(dependencies = [ComponentA::class])
interface ComponentB {
...
}
view raw ComponentB.kt hosted with ❤ by GitHub

Dagger component dependencies mechanism

 

interface ProviderA {
// provided dependencies
}
@Component
interface ComponentA : ProviderA {
...
}
view raw ComponentA.kt hosted with ❤ by GitHub
@Component(dependencies = [ProviderA::class]
interface ComponentB {
...
}
view raw ComponentB.kt hosted with ❤ by GitHub

Dagger component dependencies on plain interfaces

 

Dagger component that extends provider interface from the API module

interface DataProvider {
val imagesRepository: ImagesRepository
val usersRepository: UsersRepository
}

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

 

class ImagesEntryImpl @Inject constructor() : ImagesEntry() {
@Composable
override fun NavGraphBuilder.Composable(...) {
val viewModel = injectedViewModel {
DaggerImagesComponent.builder()
.dataProvider(LocalDataProvider.current)
.build()
.viewModel
}
ImageListScreen(viewModel, ...)
}
}
import androidx.lifecycle.ViewModel
class ImagesViewModel @Inject constructor(...) : ViewModel() { ... }
@Composable
fun ImageListScreen(vm: ImagesViewModel, ...) { ... }

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 = viewModel(
key = key,
factory = object : ViewModelProvider.Factory {
override fun <VM : ViewModel> create(modelClass: Class<VM>): VM {
return viewModelInstanceCreator() as VM
}
}
)

Body of injectedViewModel function

 

interface DataProvider { ... }
val LocalDataProvider = compositionLocalOf<DataProvider> {
error("No data provider found!")
}
val dataProvider: DataProvider = ...
CompositionLocalProvider(LocalDataProvider provides dataProvider) {
NavHost(...) { ... }
}
val viewModel = injectedViewModel {
DaggerImagesComponent.builder()
.dataProvider(LocalDataProvider.current)
.build()
.viewModel
}

Using composition locals for component dependencies mechanism

 

Dagger module that provides a feature entry

 

@Module
interface ImagesEntryModule {
@Binds
@Singleton
@IntoMap
@FeatureEntryKey(ImagesEntry::class)
fun imagesEntry(entry: ImagesEntryImpl): 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.

@Module(
includes = [
ImagesEntryModule::class,
ProfileEntryModule::class
]
)
interface NavigationModule
@Singleton
@Component(
modules = [NavigationModule::class]
)
interface AppComponent {
val destinations: Destinations
}

Gathering all feature entries in the root Dagger component

 

@Singleton
@Component
interface AppComponent {
val context: Context
@Component.Factory
interface Factory {
fun create(@BindsInstance Context 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 context: Context
}
@Singleton
@Component
interface CommonComponent : CommonProvider {
@Component.Factory
interface Factory {
fun create(@BindsInstance context: Context): CommonComponent
}
}

Defining a Dagger component for common dependencies in the project

 

@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

 

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

Feel free to share your thoughts or concerns in the comments section.

Thanks to Omolara Adejuwon.

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE
blog
I recently found a bug that would cause a crash in all the apps…
READ MORE

Leave a Reply

Your email address will not be published.

Fill out this field
Fill out this field
Please enter a valid email address.

Menu