UseCase Red Flags and Best Practices in Clean Architecture

Detailed guidelines about Use Cases.

ilyas ipek
Teknasyon Engineering
6 min readApr 29, 2024

--

Designed using CodeImage

Clean architecture is extremely useful, especially in big projects. However, applying it incorrectly is like praying all your life to the wrong god — all pain, no gain :)

In this series, we will explore all the best practices and red flags of each layer, explaining why one approach is superior to another and identifying the red flags that cause frustration, but just like romantic relationships, we ignore them anyway 🤦‍♂️

What is the responsibility of the UseCase?

The use case is responsible for encapsulating the business logic for a single reusable task the system must perform.

Here is a breakdown of this definition…

1- Business logic focuses on the WHAT the product team needs to achieve when they describe to us the task. A good indicator that your use-case is doing only business logic would be if you can use it in a different platform like iOS.

For example: If we want to make a payment, business logic looks like…

  • Start transaction
  • Send payment
  • Finalize Transaction
  • Handle any errors if needed
class SendPayment(private val repo: PaymentRepo) {

suspend operator fun invoke(
amount: Double,
checkId: String,
): Boolean {
val transactionId = repo.startTransaction(params.checkId)
repo.sendPayment(
amount = params.amount,
checkId = params.checkId,
transactionId = transactionId
)
return repo.finalizeTransaction(transactionId)
}
}

2- Single task: A use-case should have only one task — mostly one public function- to be concerned about.

Why? When a use-case is only responsible for one task, it will be reusable, testable and it forces the developer to pick a good name instead of a generic one.

The use cases that don’t focus on one task usually end up being a wrapper of the repository + hard to put in the right package + it forces the developer to read all of the use cases to see what it does.

// DON'T ❌ - Generic use case for mulitple tasks 
// The functionality hard to discover if the developer
// didn't read the use case, which is very hard in a big code base.
class GalleryUseCase @Inject constructor(
/*...*/
) {

fun saveImage(file: File)

fun downloadFileWithSave(/*...*/)

fun downloadImage(/*...*/): Image

fun getChatImageUrl(messageID: String)
}

// DO ✅ - Each use case should have only one responsiblity
class SaveImageUseCase @Inject constructor(
/*...*/
) {
operator fun invoke(file: File): Single<Boolean>

// Overloading is fine for same use-case responsibility
// but with different set of params.
operator fun invoke(path: String): Single<Boolean>
}

class GetChatImageUrlByMessageIdUseCase() {
operator fun invoke(messageID: String): Url {...}
}

Note: Overloading is a conventional thing to do, so you may need to discuss it with your team.

Another solution is creating a use case for each one, e.g. GetUser, GetUserByUserId, GetUserByUsername, etc.

But I think overloading makes more sense because it’s very hard to name things when we have 4-5 params :)

2- Naming 🔤

The use-case class naming is simple: verb in present tense + noun/what (optional) + UseCase.

Examples: FormatDateUseCase, GetChatUserProfileUseCase, RemoveDetektRulesUseCase, etc.

Function names can be either using invoke operator or normal function names…

The function can be either using the invoke operator or regular function names.

class SendPaymentUseCase(private val repo: PaymentRepo) {

// using operator function
suspend operator fun invoke(): Boolean {}

// normal names
suspend fun send(): Boolean {}
}


// --------------Usage--------------------

class HomeViewModel(): ... {

fun startPayment(...) {
sendPaymentUseCase() // using invoke
sendPaymentUseCase.send() using normal functions
}
}

In my opinion, invoke operators are better because…

  1. It forces the developer to pick a good name for the use case.
  2. It reduces picking an additional name for the function.
  3. It’s easier to use.
  4. It enables us to easily add overloads for the same responsibility and add a different function with different responsibilities looks weird and pointed out during reviews.

3- Thread safety 🧵

Use cases should be main-thread safe, meaning that any heavy operation is handled on a separate thread, and the use-case remains callable from the main-thread.

// DON'T ❌ - Adding big lists and sorting operations are heavy 
// and should be done on different thread.
class AUseCase @Inject constructor() {
suspend operator fun invoke(): List<String> {
val list = mutableListOf<String>()
repeat(1000) {
list.add("Something $it")
}
return list.sorted()
}
}

// DO ✅
class AUseCase @Inject constructor(
// or default dispatcher
@IoDispatcher private val dispatcher: CoroutineDispatcher,
) {
suspend operator fun invoke(): List<String> = withContext(dispatcher) {
val list = mutableListOf<String>()
repeat(1000) {
list.add("Something $it")
}
list.sorted()
}
}

// DON'T ❌
// Don't switch context when u are not doing a heavy operation or
// just calling a repositor since repo functions should be main-thread safe.
class AUseCase @Inject constructor(
private val repository: ChannelsRepository,
// or default dispatcher
@IoDispatcher private val dispatcher: CoroutineDispatcher,
) {
suspend operator fun invoke(): List<String> = withContext(dispatcher) {
repository.getSomething()
}
}

4- Red Flags for Use-cases 🚩🚩🚩🚩

1- Having any non-domain class as input, output, or in the body of the use-case like ui-models, data-models, Android-related imports or ui-domain/domain-data mappers.

Why? This violates the use-case’s responsibility and makes it less reusable.
Example 1: Doing data-domain mapping is the repository responsibility and ui-domain mapping is the ViewModel/presenter responsibility.
Example 2: Returning a UI model that’s specific to a screen will make the use case usable only on that screen.
Example 3: Deciding the actual error message in the use case. This is the presentation's responsibility, a use case should only return the error, not the displayable message.

// DON'T ❌ - Don't use any android related classes/imports
class AddToContactsUseCase @Inject constructor(
@ApplicationContext private val context: Context,
) {
operator fun invoke(
name: String?,
phoneNumber: String?,
) {
context.addToContacts(
name = name,
phoneNumber = phoneNumber,
)
}

2- Having more than 1 public function unless it’s an overload for the same responsibility…

Why? This makes the use case hard to discover and forces the developer to read a lot of use cases to find what he/she is looking for.

3- Having non-general business rules defined in the use-case, usually means a screen-specific logic.

Why? This makes the use-case not reusable for any other screen/use-case.

4- The use case should not contain mutable data. You should instead handle mutable data in your UI or data layers. This can produce wired behaviors especially when used in more than one screen.

// DON't ❌ 
class PerformeSomethingUseCase @Inject constructor() {
val list = mutableListOf<String>()
suspend operator fun invoke(): List<String> {
repeat(1000) {
list.add("Something $it")
}
return list.sorted()
}
}

7- Having a generic name for a use-case like LivestreamUseCase, UserUseCase, GalleryUseCase, etc.

Why? This violates the single responsibility principle and forces the developer to read a lot of use cases to find what he/she is looking for.

Common Questions ⁉

1- Should I use abstraction with use cases?

In many articles and guidelines, you will see a use-case implementation with abstraction like this…

interface GetSomethingUseCase {
suspend operator fun invoke(): List<String>
}

class GetSomethingUseCaseImpl(
private val repository: ChannelsRepository,
) : GetSomethingUseCase {
override suspend operator fun invoke(): List<String> = repository.getSomething()
}

// Than bind this implementation to the interface using dependecy injection

Abstraction offers various advantages, such as the ability to provide multiple implementations and enable mocking for unit testing.

However, use cases typically have a single implementation and don’t require mocking, as they just dictate domain rules. When testing ViewModels, it’s preferable to test the real app logic rather than mock it.

In my opinion, avoid abstraction with use cases unless multiple implementations are required.

2- What to do with useless use cases?

Sometimes you end up with many use cases that only wrap a repository function…

class GetSomethingUseCase @Inject constructor(
private val repository: ChannelsRepository,
) {
suspend operator fun invoke(): List<String> = repository.getSomething()
}

And you might ask “rightfully”, how about using the freaking repository function in the ViewModel instead?

Well, google agrees with you on this one :) But here are the pros and cons of always accessing data-layer using use cases…

  • Many use cases will add complexity for little benefit ( big — ).
  • Always using use cases will protect the code from future change (+)
    e.g. if sending payment needs another step you will need to create a new use-case and then refactor every ViewModel that was using that repo function to use the use-case instead.
  • Always using use cases will act like documentation and help you understand what the project does better(+).
    e.g. a new developer can understand what the project does just by reading the names of the use cases.
  • Always using use cases will help with consistency and reduce decision fatigue (+).

The decision is up to you and your team and may vary depending on the project size.

3- Can I use cases in another use case?

Yep, that’s not only acceptable but also recommended. Breaking down tasks into smaller, more manageable use cases enhances modularity and reusability.

Useful links

  1. Why you need Use Cases/Interactors
  2. Domain Layer by Android Developers
  3. Android — How to write the best Usecase/Interactors ever!
  4. 5 Use Case Mistakes by Philipp Lackner

That’s it and I hope you loved the blog ❤️❤️❤️

ByeByeByeBye

--

--

Android developer @teknasyon, writes about Android development and productivity.