How To Avoid Use Cases Boilerplate in Android sdk

 

Since the dawn of layered architectures, Use Cases have had a controversial reputation.

While on the one side, they help you adhere to S.O.L.I.D. principles and improve your code readability; on the other, they are sometimes “useless” (the only thing they do is call a repository method).

Because of that, the industry converged on the following four approaches (to be used only when use cases are just a call to a repository method):

Approach 1: Skip the Use Case

With this approach, you have a Use Case only if it is doing something, while if it is “useless,” you call the repository directly.

Here’s an example:



/**
*@author Laxmi kant
*
*/

data class SomeModel(val id: String, val name: String)

interface SomeModelRepository {
    suspend fun getSomeModels(): Answer, String>
}

class SomeViewModel(private val someModelRepository: SomeModelRepository) : ViewModel() {
    fun load() {
        viewModelScope.launch {
            val result = someModelRepository.getSomeModels()
            // Do something with the result
        }
    }
}

Pros

  1. Less code to write
  2. No redundancy: a change to the repository method will not require a Use Case change.

Cons

  1. Inconsistency
  2. Scalability: if the repository method requires extra parameters that are not coming from the presentation layer, all the VMs using it will need to be updated, and this, when it comes to core repositories, might be a big refactor.
  3. ISP violation: repositories usually have many methods. Hence when you have a repository as a collaborator, your class will require a recompilation every time a method you are not using gets added, removed, or changed.
  4. Cheating: my experience with this approach is that lazy engineers will find excuses not ever to have a Use Case and therefore overload either ViewModels or Repositories with business logic.

Approach 2: Keep the Use Case

With this approach, you don’t care whether your use case is doing something or not since you are always going to have a use case:

Here’s an example:



/**
*@author Laxmi kant
*
*/

data class SomeModel(val id: String, val name: String)

interface SomeModelRepository {
    suspend fun getSomeModels(): Answer, String>
}

interface GetSomeModelUseCase {
    suspend operator fun invoke(): Answer, String>
}

class GetSomeModelUseCaseImpl(
    private val someModelRepository: SomeModelRepository
) : GetSomeModelUseCase {
    override suspend fun invoke() = someModelRepository.getSomeModels()
}

class SomeViewModel(private val getSomeModelUseCase: GetSomeModelUseCase) : ViewModel() {
    fun load() {
        viewModelScope.launch {
            val result = getSomeModelUseCase()
            // Do something with the result
        }
    }
}

Pros

  1. Consistency.
  2. Scalability: if the repository method requires extra parameters not coming from the presentation layer, all the VMs using the use case won’t need to be updated because they are protected by the use case interface.
  3. ISP adherence.

Cons

  1. More code to write.
  2. Redundancy: if your repository method signature changes for getting different parameters from the presentation layer, you will have to update the use case class, tests, and interface.

NB: when I say “parameters coming from the presentation layer” I don’t mean presentation models passed to the use case, I mean input coming from the presentation layer that is mapped into domain models.

In pure OOP, these two approaches are the only options you have but when you add functional programming to the mixture, things change for the better.

Approach 3: Functional Use Cases With Type Aliases

A while ago, I came out with the Functional Use Case approach, where you have a function or a type alias instead of having an interface for the Use Case.

Here’s an example:



/**
*@author Laxmi kant
*
*/

data class SomeModel(val id: String, val name: String)

interface SomeModelRepository {
    suspend fun getSomeModels(): Answer, String>
}

typealias GetSomeModelUseCase = suspend () -> Answer, String>

class SomeViewModel(private val getSomeModelUseCase: GetSomeModelUseCase) : ViewModel() {
    fun load() {
        viewModelScope.launch {
            val result = getSomeModelUseCase()
            // Do something with the result
        }
    }
}

fun provideViewModel(someModelRepository: SomeModelRepository): SomeViewModel {
    return SomeViewModel(someModelRepository::getSomeModels)
}



Because you don’t have an interface but a higher order function, you can inject whatever you want; therefore, swapping a repository method for a use case class is just a matter of updating the DI layer.

Overall, this was a good approach since it removes boilerplate, but given that most Android projects are heavily influenced by DI frameworks like Koin and Dagger, which require types to work, this approach came with an additional complexity for the DI layer.

In Dagger, if you have multiple suspend () -> Answer<List<SomeModel>, String> you won’t be able to do the following:

@Provides
@Singleton
fun provideGetSomeModelUseCase(
someModelRepository: SomeModelRepository
): GetSomeModelUseCase {
return someModelRepository::getSomeModels
}

Unless for each specific function, you don’t add the ugly @Named hack.

In Koin, you won’t be able to do the following:

single<GetSomeModelUseCase> {
get<SomeModelRepository>()::getSomeModels
}

Pros

  1. Less code to write.
  2. No redundancy: a change to the repository method will not require a Use Case change.
  3. Consistency.
  4. Scalability: if the repository method requires extra parameters not coming from the presentation layer, all the VMs using the use case won’t need to be updated because they are protected by the use case interface.
  5. ISP adherence.

Cons

  1. Doesn’t work well with DI frameworks

This was the best approach I could come up with back then, and as Howard Stark once said:

“I’m limited by the technology of my time, but one day you’ll figure this out. And when you do, you will change the world. What is and always will be my greatest creation… is you.”

[FINAL] Approach 4: Functional Use Cases With Functional Interfaces

After years, JetBrains answered my prayers and introduced Functional Interfaces (SAM).

Thanks to this Kotlin update, functions can be injected as Functional Interfaces, unlike type aliases, are “real types.”

Here’s an example:



/**
*@author Laxmi kant
*
*/

data class SomeModel(val id: String, val name: String)

interface SomeModelRepository {
    suspend fun getSomeModels(): Answer, String>
}

fun interface GetSomeModelUseCase : suspend () -> Answer, String>

class SomeViewModel(private val getSomeModelUseCase: GetSomeModelUseCase) : ViewModel() {
    fun load() {
        viewModelScope.launch {
            val result = getSomeModelUseCase()
            // Do something with the result
        }
    }
}

fun provideViewModel(someModelRepository: SomeModelRepository): SomeViewModel {
    return SomeViewModel(someModelRepository::getSomeModels)
}


The Dagger provides a function that can be fixed this way:

@Provides
@Singleton
fun provideGetSomeModelUseCase(
someModelRepository: SomeModelRepository
): GetSomeModelUseCase {
return GetSomeModelUseCase(someModelRepository::getSomeModels)
}

And the Koin provides function as well:

single<GetSomeModelUseCase> {
GetSomeModelUseCase(get<SomeModelRepository>()::getSomeModels)
}

Pros

  1. Less code to write.
  2. No redundancy: a change to the repository method will not require a Use Case change.
  3. Consistency.
  4. Scalability: if the repository method requires extra parameters that are not coming from the presentation layer, all the VMs using the use case won’t need to be updated because they are protected by the use case interface.
  5. ISP adherence.
  6. Work well with all DI frameworks

Cons: N/A

We finally have a solution with no cons.

Summary

Use cases are a crucial part of clean architecture, and developers always had a love/hate relationship with them.






Post a Comment

0 Comments
* Please Don't Spam Here. All the Comments are Reviewed by Admin.