Replace Useless Cases with Interfaces
A previous blog post discussing the value in removing useless cases, encouraged interacting with the repository layer directly instead of creating use case classes that acted as mere pass throughs for the repository layer. This is a great approach, but might not work in all scenarios. For example a team might want to always have a use case either for consistency sake or for separation of concerns. This post offers a different solution to keep the architectural benefits of use cases without the bloat of pass through classes.
The Approach #
The biggest issue with creating pass through use cases, is the existence of a bunch of useless code that does nothing valuable. However, there might be reasons a given project chooses to adopt an "always have a use case" philosophy. What that approach does not imply is that a class must exist for each use case. By leveraging an interface, the use case can be implemented by the repository class instead of a dedicated class. This keeps the architecture while removing the bloat.
Example #
This example shows a basic implementation of a counter using this pattern.
First, start with a basic repository.
class CounterRepository {
private val _value = StateFlow(0)
val value: Flow<Int> = _value
fun addOne() { _value.update { it + 1 } }
fun subOne() { _value.update { it - 1 } }
}
Then add a set of use case interfaces like this.
interface AddUseCase { fun addOne() }
interface SubUseCase { fun subOne() }
interface GetValueUseCase {
val value: Flow<Int>
}
Now the repository can implement these interfaces.
class CounterRepository:
AddUseCase,
SubUseCase,
GetValueUseCase {
override val value: Flow<Int> ...
override fun addOne() ...
override fun subOne() ...
}
Finally, the use cases can be used in the code like usual.
class CountingViewModel(
private val addOne: AddOneUseCase,
private val subOne: SubUseCase,
private val getValue: GetValueUseCase,
): ViewModel() {
...
}
To complete the work, fulfill the dependencies using the repository for the use cases. This will be dependent on the dependency framework used in the project.
Aside: Leveraging the invoke operator #
A common pattern with use cases is to expose the invoke operator. With this approach that isn't possible in the interface due to the overloading of the invoke operator by the repository from multiple use cases. A work around is to add an invoke operator extension on each use case interfaces which avoids both the conflicting function names and the need for the repository to implement the function with the trade-off that now the use case exposes two functions.
interface AddUseCase { fun addOne() }
operator fun AddUseCase.invoke() {
this.addOne()
}
While this approach adds a small layer of pass through, it is a single function that is easily updated if the time comes to add more functionality to the use case.
Conclusion #
With those small changes, the use cases now serve an architectural purpose without introducing additional unnecessary bloat. To see a more fleshed out example using Hilt, check out this gist. Until next time, thanks!
Did you find this content helpful?
Please share this post and be sure to subscribe to the RSS feed to be notified of all future articles!
Want to go above and beyond? Help me out by sending me $1 on Ko-fi. It goes a long way in helping run this site and keeping it advertisement free. Thank you in advance!