Android Architecture – multiple modules example

In a previous post I wrote about my interpretation of how to structure a multiple module Android app with clean architecture, without going into detail about how you actually put it into practice. In this post I will show a real example of using that architecture, what goes into each layer, and how to communicate between the layers.

The demo app

In order to demonstrate this I’ll be developing a simple app with just two screens – a list screen and a details screen. Rather than use the usual GitHub api or HackerNews api, I’ll be using TheDogAPI; I find it a bit more interesting and it makes me feel like I’m actually getting away from work for a few hours.

I’ll also be using the usual Android technologies such as AAC, Retrofit, Gson, Koin, and a few others that are all fairly standard and don’t need any explanation.

This app doesn’t use feature modules, dynamic feature modules, instant app feature modules, or any other fancy stuff like that, I’ll save that for another day. Also note that the app won’t win any design awards, the UI is very basic. Here are the wireframes for the app.


Setting up the project

The first thing to do is create the app structure in Android Studio with the four modules for the app – app, presentation, domain, and data, as detailed in the previous post. As a slight improvement to that project structure, move the colors.xml file from the app module’s resource folder into the presentation module’s resource folder. Because the app module has a dependency to the presentation module the app styles or themes can use the colours from there and it keeps all the colours in one logical place.

Another good practice is to keep all the version information in the top level build.gradle file and then refer to these versions in all the module build.gradle files. That way when you need to update any versions you only need to do it in one place. You can do this by adding something like the following to the project’s build file in the buildscript section:

    ext {
        compile_sdk = 30
        min_sdk = 26
        target_sdk = 30

        kotlin_version = "1.4.31"

        junit_version = "4.13.2"
    }

Then in the module level build files you can refer to the versions like this:

android {
    compileSdkVersion compile_sdk

    defaultConfig {
        minSdkVersion min_sdk
        targetSdkVersion target_sdk
    .
    .
}

dependencies {
    .
    .
    testImplementation "junit:junit:$junit_version"
    .
    .
}

You can also remove the dependency for the Kotlin stdlib from all the module build files:

implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"

This is now added by default if you are using Kotlin 1.4+


Coding the app

You should now have an app that builds and runs with an empty MainActivty in the presentation module.

We’ll start by adding the framework for the Koin dependency injection. We’ll add the dependencies to the library in each module’s build file, and we’ll only add the necessary part of Koin for each module:

    implementation "org.koin:koin-android:$koin_version"
    implementation "org.koin:koin-android:$koin_version"
    implementation "org.koin:koin-androidx-viewmodel:$koin_version"
    implementation "org.koin:koin-core:$koin_version"
    implementation "org.koin:koin-core:$koin_version"

Then we need a Koin module for the data, domain, and presentation modules by creating a new Kotlin file in each module with a val in it. For example, here’s the data module file:

import org.koin.dsl.module

val dataModule = module {
}

These Koin modules are empty for now, when we start adding some classes that need injecting we will be adding them in here.

Then we add the code in the app module Application class that will actually build the dependency injection graph:

class DoggosApp: Application() {

    override fun onCreate() {
        super.onCreate()

        startKoin {
            androidContext(this@DoggosApp)
            modules(
                listOf(dataModule, domainModule, presentationModule)
            )
        }
    }
}

And that’s the app module finished, now we’ll get on with the main functionality of the app.


Coding the domain layer

The domain layer is for business logic. It knows nothing about Android, networks, databases, etc., it lives in glorious isolation from the outside world. But it does know about business data models and what it wants, and it tells the other modules what it wants and what they can have.

There are basically three types of objects in the domain layer:

  • Use cases – these are the business logic that the UI layer uses to get the data it needs
  • Data models – these are the data classes in the format needed by the business
  • Repository contracts – these are the contracts the data layer must conform to when the domain layer asks it for data

From the wire frames we can see we will need two data models – breed summary and breed details:

data class Breed(val id: Int, val name: String, val group: String, val imageUrl: String)
data class BreedDetail(
    val id: Int,
    val name: String,
    val group: String,
    val imageUrl: String,
    val temperament: String,
    val bredFor: String,
    val weight: SizePair,
    val lifeSpan: SizePair
) {

    data class SizePair(val lower: Float, val upper: Float)
}

The UI layer will get this data from two use cases:

interface GetBreedsUseCase {
    suspend operator fun invoke(): List<Breed>
}

interface GetBreedDetailsUseCase {
    suspend operator fun invoke(name: String): BreedDetail
}

We want a repository to give us this data, we don’t care how or where from, just give us this data in the format we want it. So we create a contract that the data module must conform to:

interface DogRepository {

    suspend fun getBreeds(): List<Breed>
    
    suspend fun getBreedDetails(name: String): BreedDetail?
}

Use cases get their data from repositories or other use cases. The implementation of our use cases is very simple:

class GetBreedsUseCaseImpl(private val dogRepository: DogRepository): GetBreedsUseCase {
    override suspend fun invoke(): List<Breed> = dogRepository.getBreeds()
}

class GetBreedDetailsUseCaseImpl(private val dogRepository: DogRepository): GetBreedDetailsUseCase {
    override suspend fun invoke(name: String): BreedDetail = dogRepository.getBreedDetails(name)
}

Use cases are not always this simple, they can contain business logic. But often they are just one-liners like this that simply return whatever the repository gives us. You may think they are a waste of time when they are this simple, why not let the UI layer just use the repository interface directly? But they are actually serving a very useful purpose, they insulate the UI layer from any future changes to business logic, and maintain all business logic in one standard place. Also, the domain layer is a pure Java/Kotlin library so it is very simple to unit test any logic in here.

We’ll then need to tell Koin how to build the dependency injection graph. This is done by adding the following object creation calls to the domainModule.kt file.

val domainModule = module {

    single<GetBreedsUseCase> { GetBreedsUseCaseImpl(get()) }

    single<GetBreedDetailsUseCase> { GetBreedDetailsUseCaseImpl(get()) }
}

Coding the data layer

The main technologies I use in this layer these days are OkHttp, Retrofit, Apollo GraphQL, Gson, Room database, and Jetpack DataStore. These all support Kotlin coroutines and Kotlin Flow, which are my preferred ways of marshalling the data calls.

For this example we’re just using a simple Rest API and won’t be using any local database or in-memory caching, so we’ll just use OkHttp, Retrofit, and Gson. You can see in the linked git repo how the components for this are setup in their own object creation functions which are then injected from dataModule.kt:

val dataModule = module {

    single { createGson() }

    single { createGsonConverter(get()) }

    single { createOkHttpClient() }

    single<DogApi> { createRetrofit(get(), get(), BuildConfig.API_URL) }

    single<DogRepository> { DogRepositoryImpl(get()) }
}

The interesting parts of the data module are the parts that demonstrate how the separation of concerns between the modules are enforced. First, the repository implements the domain module’s repository interface to ensure it is providing data in the desired format:

class DogRepositoryImpl(
    private val dogApi: DogApi,
    private val breedMapper: Mapper<BreedDto, Breed>,
    private val breedDetailMapper: Mapper<BreedDetailDto, BreedDetail>
): DogRepository {

    override suspend fun getBreeds(): List<Breed> {
        return dogApi.getBreeds().map { breedDto ->
            breedMapper.map(breedDto)
        }
    }

    override suspend fun getBreedDetails(name: String): BreedDetail? {
        return dogApi.getBreedDetails(name)?.let { breedDetailDto ->
            breedDetailMapper.map(breedDetailDto)
        }
    }
}

The second interesting thing is the format of the data in the data module. As you can see in the repository code above, the data from the api is being mapped to the domain module’s data classes Breed and BreedDetail. This is because the data coming from the api is in the format specified by the api (i.e. Data Transfer Objects) which are not appropriate for the business model or UI layer. So we should always map the DTOs from apis, databases etc. into business models.

To me, mappers are a crucial part of the architecture. They are simple to write and maintain, and very importantly they are easy to unit test. I won’t go into detail on the mapper objects used in this example, check the git repository to see them.

The rest of the data layer for this example is just the Retrofit interface for the api.


Coding the presentation layer

All that’s left to do is get the data from the use cases to the view models and views. The use cases are injected into the view models as constructor parameters (using the use case interfaces) from the presentationModule injection file.

We can then call the use case invoke() function within the view model coroutine scope and assign the returned value to a LiveData object. The LiveData object is then observed from the Activity or Fragment.

class MainViewModel(private val getBreeds: GetBreedsUseCase): ViewModel() {

    private val _state = MutableLiveData<MainState>()
    val state: LiveData<MainState> = _state

    init {
        viewModelScope.launch {
            _state.value = MainState.BreedsList(getBreeds())
        }
    }

    sealed class MainState {
        data class BreedsList(val breeds: List<Breed>): MainState()
    }
}
class MainActivity : AppCompatActivity() {

    private val viewModel by viewModel<MainViewModel>()
    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        viewModel.state.observe(this) { state ->
            when (state) {
                is MainState.BreedsList -> displayBreeds(state.breeds)
            }
        }
    }
    
    private fun displayBreeds(breeds: List<Breed>) {
    }
}

Using a LiveData object in your view model and observing it in the view completely decouples the view from the view model. It also makes them easier to test, which will be the subject of my next post about this architecture pattern.

You can see the full implementation of this example app in the GitHub repository.


Leave a Reply

Your email address will not be published. Required fields are marked *


The reCAPTCHA verification period has expired. Please reload the page.