UseCases: Injectable Functions

This post demystifies use cases: they’re just functions with dependency injection. Single-purpose classes that simplify complex logic by letting DI handle dependencies, making code cleaner and testable.

UseCases: Injectable Functions

If you’ve spent any time in modern Android development—or really any ecosystem that leans on clean architecture—you’ve probably bumped into the concept of "use cases." Maybe you’ve seen them praised as a cornerstone of separation of concerns (SoC), or maybe you’ve rolled your eyes at yet another layer of abstraction. There’s a lot of confusion and even some resentment around use cases, so let’s cut through the noise: a use case is just an injectable function. That’s it. It’s a single-purpose
component that behaves like a function but gets its dependencies handed to it via your dependency injection framework of choice. Let me explain.

TLDR

Use cases are single-purpose classes that act like functions but use dependency injection to handle their inputs—think reusable, testable functions without the parameter mess.

What's a UseCase?

A use case is a class that performs one specific piece of business logic. Consider the following example:

public class ConnectToServerUseCase @Inject public constructor(
    private val serverRepository: ServerRepository,
    private val connectionManager: VPNConnectionManager,
    private val serverConnectionRecordRepository: ServerConnectionRecordRepository,
    private val clock: Clock
) {

    public suspend operator fun invoke(location: LocationCode): Boolean {
        val server = serverRepository.get(at = location)
            .firstOrNull()
            ?: return false

        val connected = connectionManager.connect(server)

        if (connected) {
            serverConnectionRecordRepository.record(server, timestamp = clock.now())
        }

        return connected
    }
}

The above class ConnectToServerUseCase performs a simple business logic task for a VPN application: connects to a VPN server in the provided location. When performing this task, it might have some related logic it needs to perform every time, such as recording information about the connection and time, and returning a result of whether the connection was successful. Perhaps this function is invoked on multiple screens within an application, so isolating it into its own reusable component keeps things DRY and testable. The component can easily be injected into your ViewModel, and since we used Kotlin's `invoke` operator function, the component can be invoked as if it were a function within the ViewModel itself.

public class ServerListViewModel @Inject constructor(
    private val connectToServer: ConnectToServerUseCase
    ...
) {
    ...

    public fun selectLocation(location: LocationCode) {
        coroutineScope.launch {
            val isConnected = connectToServer(location)
            emitUpdatedState { current -> current.copy(isConnected) }
        }
    }
}

Now, the ConnectToServerUseCase can easily be tested by mocking its dependencies. The ViewModel can focus on performing application logic and updating the state appropriately. And the UseCase can be reused in other parts of the application. Clearly, this is a simple example created for this blog post, but it illustrates the point: a UseCase is just an "injectable" function.

We could have just injected all those components into the ViewModel and used them in the selectLocation function, but that wouldn't be reusable throughout the
application, it would be more difficult to test (as a whole ViewModel with multiple functions would have to be tested now), and that is not scalable for real world applications (as the functionality grows it becomes difficult to maintain). Likewise, we could have created a function, which takes all those components as parameters instead of injecting them in, but that would add pointless complexity to the call-site. So, instead we rely on our dependency injection framework to handle providing dependencies and we let our UseCases rely on performing business logic.

Let's take a deeper look.

Why Not Just a Function?

Simple stuff like fun add(a: Int, b: Int) doesn’t need DI. But logic with dependencies—like connecting to a server—gets messy:

suspend fun connectToServer(
    location: LocationCode,
    repo: ServerRepository,
    manager: VPNConnectionManager,
    recordRepo: ServerConnectionRecordRepository,
    clock: Clock
) {
    ...
}
val success = connectToServer("US-EAST", repo, manager, recordRepo, clock)

This violates DRY (Don't Repeat Yourself)—every call site repeats the wiring. A use case hands that to DI, keeping calls clean.

The Payoff

  • DI Magic: No manual dependency passing.
  • Separation: One task, one component.
  • Testing: Inject mocks easily.
  • Consistency: Uniform structure.
  • Reuse: Consistent reuse of code throughout the application.

Are Use Cases Meant for Everything?

No. They’re one tool in your toolbox, not the whole shed. Use cases shine when you’ve got a distinct piece of business logic that needs dependencies and reuse—like fetching user data and deriving data from it. But they don’t fit every scenario. Need a simple utility function like formatDate(date: Date)? No DI needed, no class required—just keep it a plain function. Throwing a use case at every line of code is overkill and misses the point. They’re a piece of the puzzle, but they don't fit everywhere. Know your problem, pick your tool.

Why Do Use Cases Get So Much Pushback?

Use cases can feel like overkill for small apps—more files, more boilerplate. Fair point. But they shine as complexity grows, keeping code modular and easier to scale. Not everything needs a UseCase, for instance if a UseCase is just acting as a intermediary simply delegating to a repository then it is definitely overkill. Or, if you find yourself trying to fit something into a UseCase, it might be a bit too much.

I don't think UseCases are meant for everything, you'll still use your utility functions, "manager" components, repository abstractions, or simple functions, but they work great at separating complex business logic into digestible portions, reducing redundancy, and simplifying scalability.

Embrace the Injection

Use cases turn functions into lean, DI-powered machines—simple to call, easy to test, built to scale. They’re not meant to overcomplicate; they’re meant to simplify where it counts. A good UseCase is simply a function that performs one task, injects its dependencies, and can be invoked just like a traditional function.