UseCase driven development in Android: A step towards CLEAN Code.

UseCase driven development in Android: A step towards CLEAN Code.

While there are many architectural patterns in the market (MVVM, MVP), the need for a better approach is always there based on the use case of the application. Use case classes, which we will be looking at in this article are probably the most recognizable aspect of “Clean Architecture”. This approach to software architecture became very popular lately.

In this article, we will touch upon the following points:

  • What exactly is a use-case

  • Implementing a use-case in Kotlin

  • Advantages of using a use-case-driven architecture.

So let's get started with it.

What exactly is a use-case ?

A use-case as the name suggests is a use-case of the application or you can say a particular business logic of the application or a feature. A simple example would be a EmailPasswordLoginUseCase. This use-case should include all the business logic that is related to the login of the user using the email password combinations. This use-case class can include validations and network operations of logging the user into the app.

Now that we know what a use-case represents, let's try to understand how we can build a use-case but before that we need to understand something about the invoke operator in kotlin.

Kotlin internally has an invoke operator that can be used to invoke functions. So all functions that are declared in Kotlin internally include a invoke operator. This was added to make sure there can be null safe invocations on nullable lambda functions.


val compute: ((a: Int, b: Int) -> Int)? = { a, b -> a + b }

fun main() {
    compute?.invoke(10, 20)
}

Since the compute lambda function is nullable, Kotlin doesn't allow you to invoke it directly. You will have to use the null safety operator along with the invoke operator to call this function.

But it doesn't matter if the function is nullable or not. Kotlin internally has this operator placed in all the functions/lambda-functions irrespective of whether they are nullable or not . So the above could also have been this


val compute: ((a: Int, b: Int) -> Int) = { a, b -> a + b }

fun main() {
    compute.invoke(10, 20)
    //Same as calling the function
    //compute(10, 20)
}

The above is the same as calling the function without the invoke keyword. From the Kotlin documentation, we see that the parentheses are translated to calls to invoke with the appropriate number of arguments.

ExpressionTranslated to
a()a.invoke()
a(i)a.invoke(i)
a(i, j)a.invoke(i, j)
a(i_1, ..., i_n)a.invoke(i_1, ..., i_n)

Implementing a use-case in Kotlin

Now that we understand the interesting feature of the Kotlin library to translate parentheses to invoke operator functions. Let's try to use that in our own implementation. Below is a use-case example that demonstrates the use of the invoke operator function. We will try to build an EmailPasswordLoginUseCase for this article as an example.


class EmailPasswordLoginUseCase() {

    operator suspend fun invoke(email: String, password: String): User {
        //Some network operations and stuff here.
    }
}

fun main() {
    val emailPasswordLoginUseCase = EmailPasswordLoginUseCase()

    MainScope().launch {
        emailPasswordLoginUseCase.invoke("bawender.y@gmail.com", "*******")
        //But since we are using invoke operator function we can write it as
        emailPasswordLoginUseCase("bawender.y@gmail.com", "*******")
    }
}

Notice how the variable emailPasswordLoginUseCase can directly be invoked as a function because of the invoke operator. When you specify an invoke operator on a class, it can be called on any instance of the class without a method name. This trick seems especially useful for classes that really only have one method to be used. Since our use cases contain a single responsibility in the application architecture, they become the perfect fit for this trick.

invoke operator functions can include the suspend keyword which allows them to make use of coroutines for network operations. But they are optional and are only included in the example above for demonstration. You can pass any number of parameters to this invoke operator function just like a regular function. The above use case takes the email, and password combination and returns the User object if the authentication was successful.

Now let's focus on the internal implementation of the use-case class and how it should be interacting with the view.

Note:

For simplicity's sake I am gonna be using the Hilt dependency injection so that we don't have to worry about class creation.


class EmailPasswordLoginUseCase @Inject constructor(
    authRepo: AuthenticationRepository,
    userRepo: UserRepository,
) {

     operator suspend fun invoke(email: String, password: String): User {
        val user = authRepo.authenticateEmailPassword(email, password)
        userRepo.saveUser(user)
        return user
    }
}

Let's try to understand this class in a step-by-step fashion.

  1. We have 2 repository classes (AuthenticationRepository, UserRepository) injected into the use-case class which help us in authenticating the user and saving the user information into our application cache. You can include numerous repository classes inside the use-case as long as they serve to full fill the responsibility of the use-case class.

  2. When the invoke function is called, we perform a network operation to authenticate the user and retrieve the user object from the server. This server object is then passed to the user repository to save it to the local cache. the server object is also then passed back as the result of the function.

Now let's try to see how this use-case class can be used to interact with the view. In a normal android application, there is always a view-model/presenter/controller class that communicates with the view (Activity/Fragment) and processes the view requests. So we can inject our use-case class into the view model or any other view interaction class. For our example, we are gonna use a view-model to communicate with the view.


class LoginViewModel @Inject constructor(
   private val emailPasswordLoginUseCase: EmailPasswordLoginUseCase
): ViewModel() {

    fun loginUser(email: String, password: String) {
        viewModelScope.launch {
            val user = emailPasswordLoginUseCase(email, password)
            showToast("Welcome back ${user.fullName}")
        }
    }
}

We can inject as many use cases as we want into the view-model to based on the functionality of the view class or the feature that we are building.

Advantages of using a use-case-driven architecture.

  1. Logic Encapsulation A use-case allows you to think about your application in terms of smaller blocks that perform functionality. This means you will always create single-responsibility use cases that use resources to perform only one single task. Treat your use cases as business logic containers.

  2. Testing Since a use case has a single responsibility you can easily test it out to see if your core functionality is working as expected.

  3. High level vision & readable code Use cases benefit from a design perspective as they allow you to think of the application in simpler functional blocks. Use cases don't really dictate how the networking is happening or which endpoint or caching is needed, but rather only talk about the functionality that needs to be performed. They sit above the network and data layer and act as an intermediatory to the view-model/presenter classes. The view model also benefits from this coding style since they become clean and easy to read.

Conclusions


Use cases are a powerful way of analyzing your system to describe its behavioral aspect. I have been using this method of architecture for a while now and it all started when it didn't make any sense for the repositories to hold all the logic required for the functionality. I also couldn't write this code in the view-model classes since then they would lose reusability and the view-model classes would then become bloated with code. Testing was also greatly improved and simplified.

One thing that I haven't shown you here is error handling or validation handling because this is more of a design choice and can vary from developer to developer. I prefer to keep validations inside the use case itself since they represent the same functioning block and throw custom exceptions based on the error that occurs. The view model then catches these errors and shows the appropriate messages to the users.

If you think this article has helped you understand use-case-driven architecture, consider sharing this article with your friends.