Introduction
Earlier, we developed an Android application with a focus on Clean Architecture, Inputs/Outputs MVVM splitting, and the Repository pattern. This approach follows best practices recommended by the Android team at Google, making the codebase scalable, maintainable, and testable.
In this article, we’ll dive into one more essential concept — Use Case — and I’ll walk you through its usage and the background behind it. Embracing this pattern will make your code even more readable and testable — which is a pretty big win.
Problems with Interactors
Back in the day, we often used Interactors as middleware components between layers — for example, a Presenter could use an interactor to communicate with the domain layer. This allowed us to move some logic out of the presenter and into separate, reusable components. At the time, it was a solid solution for splitting logic and keeping things cleaner.
Let’s look at a simple example:
// Interactor
class UserInteractor {
private var username: String = "Guest"fun getUsername(): String {
return username
}
fun saveUsername(name: String) {
username = name
}
}
// Presenter
class UserPresenter(
private val view: UserView,
private val interactor: UserInteractor
) {
fun loadUsername() {
val name = interactor.getUsername()
view.showUsername(name)
}
fun updateUsername(name: String) {
interactor.saveUsername(name)
view.showSavedMessage()
}
}
As the presenter grows, so does the complexity of the logic — and that usually leads to a growing number of methods in the interactor. The bigger the presenter, the bigger the interactor becomes. Eventually, we end up with a setup full of states, methods, and variables all crammed into one place.
Obviously, such a codebase becomes tough to maintain, hard to test, and even harder to cover with proper unit tests. On top of that, this setup drifts into anti-pattern territory, breaking core principles like SOLID (especially the Single Responsibility Principle) and KISS.
That’s why I’d highlight the following pitfalls when using the interactor approach:
- Too Much Stuff in One Place
When one class handles everything — reading, writing, deleting — it ends up doing too much. That makes it harder to test and tricky to change without breaking something else. - Not Clear What It Does
A use case likeLoginUser()
tells you exactly what’s going on. But with a big interactor, it’s not obvious — is it about users, settings, or something else? - Hard to Reuse
Use cases with one job are easy to plug in wherever you need them. Interactors grow over time and become too messy to reuse easily. - Doesn’t Scale Well
Imagine having 10 features and each one has its own interactor with 5+ methods. That’s a lot to remember and a lot of code to manage. - Logic Gets Mixed Up
When everything lives in one file, it’s easy to accidentally combine things that shouldn’t be together — like login logic getting tangled with profile updates.
Use Case: From UML to Android
All the issues we just discussed can be fully or at least partially resolved by using the Use Case approach. But before jumping into the Android implementation, let’s take a quick look at what a Use Case means in UML terms. In UML, a use case is all about one clear intent — it represents a specific piece of business logic or functionality. Check out the example below to see how it’s typically visualized:
Basically, the use cases we’re about to implement follow the same idea as in UML: one intent — one use case. That simple rule helps us deal with all the earlier problems — testing gets easier, the code becomes more scalable, and the whole thing is cleaner to maintain.
Now, let’s improve the code snippet above using the Use-Case approach:
// Use-Case 1
class GetUserNameUseCase(
val repository: UserRepository,
) {
operator fun invoke(): String {
return repository.getUserName()
}
}// Use-Case 2
class SaveUsernameUseCase(
val repository: UserRepository,
) {
operator fun invoke(name: String) {
repository.save(name)
}
}
So we’ve just split the interactor into two use cases. I recommend using the invoke operator function, which allows us to treat their use-case names as functions. On top of that, it’s much easier to test it this way.
The usage of the use cases is demonstrated in the following ViewModel:
// ViewModel
class ViewModel(
private val getUsername: GetUserNameUseCase,
private val saveUsername: SaveUsernameUseCase
) {
val userName: StateFlowfun loadUsername() {
userName.update(getUsername())
}
fun updateUsername(name: String) {
saveUsername(name)
}
}
And simple tests can be written as follows:
@Before
fun setup() {
useCase = GetUserNameUseCase(repository)
}@Test
fun `should return username from repository`() {
`when`(repository.getUserName()).thenReturn("JohnDoe")
val result = useCase()
assertEquals("JohnDoe", result)
verify(repository).getUserName()
}
@Test
fun `should save username to repository`() {
val testName = "JaneDoe"
useCase(testName)
verify(repository).save(testName)
}
How this could be incorporated into a real Repos application
My proposal is to always start with an abstraction. Given the nature of a Use-Case, which typically has only one public method, I recommend using operator functions (e.g., invoke
works well here). The following abstraction could be implemented:
// A template interface as an abstraction for all Use-Cases
interface SuspendUseCase {
suspend operator fun invoke(param: T): O
}
Its name has the suffix Suspend
to indicate that it handles a suspended result. This generic approach allows us to define specific types for both the parameters and the return type. For example, the following specific Use-Case interface could be used further:
// A particular Use-case interface
interface GetReposUseCase: SuspendUseCase>>
Based on its name, it can be used to obtain the Repos, throwing its Result wrapper. A client would use it in a function-style approach (e.g., val repos = getRepos(...)
). Here’s its implementation:
// A simple implementation of the GetReposUseCase
internal class GetReposUseCaseImpl(
private val mapper: Mapper,
private val repository: ReposRepository
) : GetReposUseCase {
override suspend operator fun invoke(param: Boolean):
ResultWithFallback> =
repository.getRepos(param).map(mapper::map)
}
Now, let’s make the task more challenging and add an additional abstract layer to the Use-Case structure. I want to implement use cases that interact solely with repositories. These will include an instance of the repository as an additional parameter type in the generic. Let’s take a look at this snippet:
// A template interface as an abstraction for all Use-Cases
interface SuspendUseCase {
suspend fun execute(param: T): O
}
I’ve changed the execute
method to allow calling one from a further abstract child. Here’s the updated code snippet:
// Extending the SuspendUseCase interface
interface RepositoryUseCase :
SuspendUseCase {var repository: R
}
Every child that implements the repository of type R
will adhere to the contract. The most appropriate approach is to use an abstract class. Let’s implement one to cover this:
// Abstract class for ensuring following the contract
abstract class BaseRepositoryUseCase(override var repository: R) :
RepositoryUseCase {suspend operator fun invoke(params: I? = null): O {
// here is a call of the SuspendUseCase inteface
return execute(params)
}
}
All we need to do is extend the BaseRepositoryUseCase
, adhering to its generic contract by providing an input class, an output class, and the repository instance that is overridden. The following implementation is good to go:
// Use-case implementation
class GetReposUseCase(repository: RepoRepository):
BaseRepositoryUseCase>, RepoRepository>(repository) {override suspend fun execute(param: Boolean): ResultWithFallback> =
// obtaining and returning result using the repository like repository.getData()
}
}
Conclusion
Thus, we’ve explored the optimal approach to using use cases from scratch, capturing client intents. I’ve demonstrated how to implement and use them in a functional manner, making them easy to test and integrate.