Book Image

Clean Android Architecture

By : Alexandru Dumbravan
Book Image

Clean Android Architecture

By: Alexandru Dumbravan

Overview of this book

As an application’s code base increases, it becomes harder for developers to maintain existing features and introduce new ones. In this clean architecture book, you'll learn to identify when and how this problem emerges and how to structure your code to overcome it. The book starts by explaining clean architecture principles and Android architecture components and then explores the tools, frameworks, and libraries involved. You’ll learn how to structure your application in the data and domain layers, the technologies that go in each layer, and the role that each layer plays in keeping your application clean. You’ll understand how to arrange the code into these two layers and the components involved in assembling them. Finally, you'll cover the presentation layer and the patterns that can be applied to have a decoupled and testable code base. By the end of this architecture book, you'll be able to build an application following clean architecture principles and have the knowledge you need to maintain and test the application easily.
Table of Contents (15 chapters)
1
Part 1 – Introduction
6
Part 2 – Domain and Data Layers
10
Part 3 – Presentation Layer

Enter clean architecture

In this section, we will discuss the concept of clean architecture, the problems it solves, and how it can be applied to an Android application.

Architecture can be viewed as the high-level solution that's required to build a system that can solve business and technical requirements. The goal should be to keep as many options on the table for as long as we can. From an Android development perspective, we've seen the platform grow a lot, and to balance the new changes that have been added to the platform with the addition of new features for our application and its maintenance, we will need to give our application a very good foundation so that it will adapt to changes. A common approach to architecture in Android development was the layered architecture, where apps would be split into three layers – the user interface, domain, and data layers. The problem here was that the domain layer depended on the data layer, so when the data layer changed, the domain layer needed to change too.

Clean architecture represents an integration of multiple types of architecture that provide independence from frameworks, user interfaces, and databases, as well as being testable. The shape resembles that of an onion, where dependencies go toward the inner layers. These layers are as follows:

  • Entity Layer: This layer is the innermost layer and is represented by objects that hold data or business-critical functions.
  • Use Case Layer: This layer implements the business logic of the system.
  • Interface Adapter Layer: This layer is responsible for converting the data between the frameworks and drivers and the use case. This will hold components such as ViewModels and presenters, as well as various converters that are responsible for converting network and persistence-related data into entities.
  • Frameworks and Drivers Layer: This layer is the outermost layer and is comprised of components such as activities, fragments, networking components, and persistence components.

Let's consider a scenario: you've recently been hired by a start-up company as their first Android engineer. You have been given a basic idea of what the app that you've been asked to develop should do, but there isn't anything too concrete; the user interface has not been finalized, the teams working on the backend are new themselves, and there isn't anything too concrete on their side either. What you do know is a set of use cases that specify what the app does: log into a system, load a list of tasks and add new tasks, delete tasks, and edit existing tasks. The product owner tells you that you should work on something while using mock data so that they can get a feel of the product and consult with the user interface and user experience teams to discuss improvements and modifications.

You are faced with a choice here: you can build the product that's been requested by the product owner as fast as possible and then constantly refactor your code for each new integration and the change in requirements, or you can take a little bit more time and factor in the future changes that will come into your approach. If you were to take the first approach, then you would find yourself in a situation where many developers found themselves, which is to go back and change things properly. Let's assume you chose the second approach. What would you need to do then? You can start decoupling your code into separate layers. You know that the UI will change, so you will need to keep it isolated so that when it is changed, the change will only be isolated to that particular section. Often, the UI is referred to as the presentation layer.

Next, you want to decouple the business logic. This is something specific to processing the data that your app will use. This is often done in the domain layer. Finally, you want to decouple how the data is loaded and stored. This will be the part where you deal with integrating libraries such as Room and Retrofit and it's often called the data layer. Because the requirements aren't definitive yet, you also want to decouple how you want to handle use cases so that if a use case changes, you can protect the others from that change. If you were to rotate the class diagram from Figure 1.4, you would see a layered approach to this example.

As we've mentioned previously, the fact that ConcreteData shows up in all the classes in our example is not a good idea. This is because, at the end of the day, the fact that we chose Retrofit and Moshi shouldn't impact the rest of the application. This is similar if it was the opposite way around and the activity or ViewModel would've done the same. At the end of the day, the way we choose to implement our UI or what networking library we should use represent details. Our domain layer shouldn't be impacted by any of these choices.

What we are doing here is establishing boundaries between the components in our system so that a change in a component doesn't impact a change in another component. In Android, even if we use the latest libraries and frameworks, we should still make sure that our domain is still protected by changes in those frameworks. Going back to the start-up example, and assuming you've chosen to decouple your components and pick the appropriate boundaries, after many demos and iterations, your company decides to hire additional developers to work on new, separate features. If those developers follow the guidelines you've set up, they can work with a minimal level of overlap.

The recommendation from Android development documentation is to take advantage of modules. One of the arguments is that it improves build speed because when you work on a certain module, it won't rebuild the others when you build the application – it caches them instead. Splitting your application into multiple modules serves another purpose.

Let's go back to the start-up. Things are going great and people love your product, so your company decides to open your APIs for other businesses to integrate into their systems. Your company also wants to provide an Android library so that it's easier for businesses to access your APIs. You already have this logic integrated into your application; you just need to export it. What features do you want to export? All? None? Do they want to persist data locally? Do they want some of the UI or not? If your modules were split with proper boundaries, then you would be able to accommodate all of those features. What we want to do is have a system where we can easily plug things in and easily plug them out.

Transitioning our previous example to this approach, we would have something like this. The ConcreteData class and ConcreteDataService would remain the same:

@JsonClass(generateAdapter = true)
data class ConcreteData(
    @Json(name = "field1") val field1: String,
    @Json(name = "field1") val field2: String
)
interface ConcreteDataService {
 
    @GET("/path")
    suspend fun getConcreteData(): ConcreteData
}

Now, we will need to isolate the Retrofit library and create the interface adapter for it. But to do that, we will need to define our entity:

data class ConcreteEntity(
    val field1: String,
    val field2: String
)

It looks like it's a duplicate of ConcreteData, but this is a case of fake duplication. In reality, as things evolve, the two classes may contain different data, so they will need to be separated.

To isolate the Retrofit call, we need to invert the dependency of our repository. So, let's create a new interface that will return ConcreteEntity:

interface ConcreteDataSource {
 
    suspend fun getConcreteEntity(): ConcreteEntity
}

In our implementation, we will invoke the Retrofit service interface:

class ConcreteDataSourceImpl(private val concreteDataService: ConcreteDataService) :
    ConcreteDataSource {
 
    override suspend fun getConcreteEntity(): 
        ConcreteEntity {
        val concreteData = concreteDataService.
            getConcreteData()
        return ConcreteEntity(concreteData.field1, 
            concreteData.field2)
    }
}

Here, we have invoked ConcreteDataService and then converted the network model into an entity.

Now, our repository will change into the following:

class ConcreteDataRepository @Inject constructor(private val concreteDataSource: ConcreteDataSource) {
 
    suspend fun getConcreteEntity(): ConcreteEntity {
        return concreteDataSource.getConcreteEntity()
    }

ConcreteDataRepository will depend on ConcreteDataSource to avoid the dependencies on the networking layer.

Now, we need to build the use case to retrieve ConcreteEntity:

class ConcreteDataUseCase @Inject constructor(private val concreteDataRepository: ConcreteDataRepository) {
 
    fun getConcreteEntity(): Flow<ConcreteEntity> {
        return flow {
            val fooList = concreteDataRepository.
                getConcreteEntity()
            emit(fooList)
        }.flowOn(Dispatchers.IO)
    }
}

ConcreteDataUseCase will depend on ConcreteDataRepository to retrieve the data and emit it using Kotlin flows.

Now, MainViewModel will need to be changed to invoke the use case. To do so, it will use the field1 object from ConcreteEntity:

@HiltViewModel
class MainViewModel @Inject constructor(private val concreteDataUseCase: ConcreteDataUseCase) :
    ViewModel() {
 
    private val _textData = MutableLiveData<String>()
    val textData: LiveData<String> get() = _textData
 
    fun loadConcreteData() {
        viewModelScope.launch {
            concreteDataUseCase.getConcreteEntity()
                .collect { data ->
                    _textData.postValue(data.field1)
                }
        }
    }
}

MainViewModel will now depend on ConcreteDataUseCase and retrieve ConcreteEntity, where it will extract field1. This will then be set in LiveData.

MainActivity will be updated to use the textData object from MainViewModel:

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Screen()
        }
    }
}
 
@Composable
fun Screen(mainViewModel: MainViewModel = viewModel()){
    mainViewModel.loadConcreteData()
    UpdateText()
}
 
@Composable
fun UpdateText(mainViewModel: MainViewModel = viewModel()) {
    val text by mainViewModel.textData.
        observeAsState("test")
    MessageView(text = text)
 
}
 
@Composable
fun MessageView(text: String) {
    Text(text = text)
}

With that, MainActivity has been updated to use LiveData, which emits a String instead of a ConcreteData object.

Finally, the Hilt module will be updated as follows:

@Module
@InstallIn(SingletonComponent::class)
class ApplicationModule {
    … 
    @Singleton
    @Provides
    fun provideHttpClient(): OkHttpClient {
        return OkHttpClient
            .Builder()
            .readTimeout(15, TimeUnit.SECONDS)
            .connectTimeout(15, TimeUnit.SECONDS)
            .build()
    }
 
    @Singleton
    @Provides
    fun provideConverterFactory(): MoshiConverterFactory = 
        MoshiConverterFactory.create()
 
    @Singleton
    @Provides
    fun provideRetrofit(
        okHttpClient: OkHttpClient,
        gsonConverterFactory: MoshiConverterFactory
    ): Retrofit {
        return Retrofit.Builder()
            .baseUrl("schema://host.com")
            .client(okHttpClient)
            .addConverterFactory(gsonConverterFactory)
            .build()
    }
 
    @Singleton
    @Provides
    fun provideCurrencyService(retrofit: Retrofit): 
        ConcreteDataService =
        retrofit.create(ConcreteDataService::class.java)
    @Singleton
    @Provides
    fun provideConcreteDataSource(concreteDataService: 
        ConcreteDataService): ConcreteDataSource =
        ConcreteDataSourceImpl(concreteDataService)
}

Here, we can see that ConcreteDataUseCase just invokes ConcreteDataRepository, which just invokes ConcreteDataSource. You may be wondering why this boilerplate is necessary. In this case, we have a bit of fake duplication. As the code grows, ConcreteDataRepository may connect to other data sources, and ConcreteDataUseCase may need to connect to multiple repositories to combine the data. The same can be said about ConcreteData and ConcreteEntity. Another benefit of this approach is the imposition of more rigor when it comes to development, and it creates consistency.

Let's look at the following diagram and see how it compares to Figure 1.4:

Figure 1.5 – Clean architecture

Figure 1.5 – Clean architecture

If we look at the top row, we will see the use case and the entity. We can also see that the dependencies go from the classes at the bottom toward the classes at the top, similar to how the dependencies go from the outer layers toward the inner layers here. A difference you may have noticed is that our example doesn't mention the usage of modules. Later in this book, we will explore how to apply clean architecture to multiple modules and how to manage them.

We are now back in the start-up, and you started working on the application, where you have defined a few entities and use cases and have put a simple UI in place. The product owner has asked you to deliver a demo with some mock data for tomorrow. What can you do? You can create a new implementation of your data source and plug in some mock objects that you can use to satisfy the conditions for the demo. You show the demo of the application and you receive some feedback about your UI. This means you can change your activities and fragments to render the data appropriately, and this won't impact any of the other components. What would happen if the use case were to change? In that situation, this would propagate into the rest of the other layers. This depends on the change, though, but this scenario is to be expected in this situation.