Fundamentals 11 min read

Comprehensive Guide to UseCase Design: Responsibilities, Naming, Thread Safety, and Anti‑Patterns

This guide explains the purpose of UseCases, best practices for their responsibilities, naming conventions, thread‑safety considerations, common anti‑patterns, and answers frequent questions, providing Kotlin code examples to illustrate correct and incorrect implementations.

Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Rare Earth Juejin Tech Community
Comprehensive Guide to UseCase Design: Responsibilities, Naming, Thread Safety, and Anti‑Patterns

About UseCase Detailed Guide

Clean architecture is extremely useful, especially in large projects, but misusing it can cause pain without benefit.

In this series we explore best practices and red‑flag usages for each layer, explain why one approach is superior to another, and highlight frustrating anti‑patterns that are often ignored.

What Is the Responsibility of a UseCase?

A UseCase encapsulates the business logic for a single reusable task that the system must perform.

Key points:

1‑ Business Logic – focuses on the content described by the product team. If the same UseCase can be used on different platforms (e.g., iOS), it indicates that the UseCase only contains business logic.

Example payment flow:

Start transaction

Send payment

Complete transaction

Handle any errors when necessary

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 UseCase should expose only one public function.

Why? A single‑task UseCase is reusable, testable, and forces developers to choose a clear name instead of a generic one.

UseCases that try to do many things often become thin wrappers around repositories and are hard to place in the correct package.

// DON'T ❌ - Generic use case for multiple tasks
// The functionality is 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 responsibility
class SaveImageUseCase @Inject constructor(
    /*...*/
) {
    operator fun invoke(file: File): Single
// Overloading is fine for same use‑case responsibility
    // but with different set of params.
    operator fun invoke(path: String): Single
}

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

Note: Overloading is traditional, so discuss it with your team.

2‑ Naming 🔤

UseCase class naming is simple: Verb + present‑tense + optional Noun + UseCase .

Examples: FormatDateUseCase , GetChatUserProfileUseCase , RemoveDetektRulesUseCase .

Functions can be invoked via the operator invoke or with regular method 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 function
    }
}

Using the invoke operator is preferred because it forces a good name, reduces the need for extra method names, is easier to use, and allows overloads for the same responsibility.

3‑ Thread Safety 🧵

A UseCase should be safe to call from the main thread; heavy operations must run on a background 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
{
        val list = mutableListOf
()
        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
= withContext(dispatcher) {
        val list = mutableListOf
()
        repeat(1000) {
            list.add("Something $it")
        }
        list.sorted()
    }
}

// DON'T ❌
// Don't switch context when you are not doing a heavy operation or
// just calling a repository 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
= withContext(dispatcher) {
        repository.getSomething()
    }
}

4‑ UseCase Red‑Flag Practices 🚩🚩🚩🚩

1. Using any non‑domain class as input, output, or inside the UseCase (e.g., UI models, Android classes, data‑model mappings). This violates responsibility and reduces reusability.

2. Having more than one public function unless they are overloads of the same responsibility.

3. Defining non‑generic business rules inside a UseCase, which usually indicates screen‑specific logic.

4. Storing mutable data inside a UseCase; mutable state should belong to UI or data layers to avoid cross‑screen issues.

// 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,
        )
    }
}

5. Using a generic name like LivestreamUseCase , UserUseCase , or GalleryUseCase . This breaks the single‑responsibility principle.

Frequently Asked Questions

1‑ Should I use abstractions in UseCases?

Many guides show an interface for a UseCase, which can provide multiple implementations and aid testing. However, most UseCases have a single implementation, so an interface often adds unnecessary complexity.

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

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

// Then bind this implementation to the interface using dependency injection

Unless you need multiple implementations, avoid abstracting UseCases.

2‑ How to handle useless UseCases?

Sometimes a UseCase merely forwards a repository call. While you might be tempted to call the repository directly, always using UseCases protects against future changes and improves code discoverability.

Many UseCases add complexity with little benefit.

Consistent UseCase usage shields code from future modifications.

UseCases serve as documentation, helping new developers understand project functionality.

Uniform UseCase usage reduces decision fatigue.

The decision depends on project size and team preferences.

3‑ Can I use a UseCase inside another UseCase?

Yes, composing UseCases is encouraged; breaking tasks into smaller, reusable UseCases enhances modularity.

KotlinBest Practicesclean architectureThread SafetyUseCase
Rare Earth Juejin Tech Community
Written by

Rare Earth Juejin Tech Community

Juejin, a tech community that helps developers grow.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.