The Complexities of Clean Architecture Use Cases

Volodymyr Shcherbyuk
11 min readMay 14, 2024

Clean Architecture has a set of rules you must follow, but strictly adhering to them can lead to problems. In this article, we will specifically discuss some issues you can have while strictly following these rules, specifically — with use cases and the Single Responsibility Principle (SRP).
This article is for people who are already familiar with clean architecture and its terms.

One of the main foundations of clean architecture is layering. The core idea is to separate the software into distinct layers with specific responsibilities, ensuring that the dependencies flow in one direction: from the outer layers to the inner layers.

One of the rules in Clean Architecture is the Dependency Rule, which states that source code dependencies can only point inward. It means, that if you want to get something or to do in the data layer, you always need to create something in the middle to act like a proxy, and usually it’s a UseCase. The use case encapsulates the business logic for a single reusable task the system must perform.

One-lined UseCase

Even if we don’t have any business rules except the rule that ‘we need to show data on the UI’; If you follow the Dependency Rule, you will need to create a UseCase that will fetch data from the data layer.

class GetUserUseCase( 
private val userRepository: UserRepository
) {

operator fun invoke(userConfig: UserConfig): Result<User> {
return userRepository.getUser()
}
}

GetUserUseCase contains no rules and is only a proxy to another layer. What if you need to have CRUD for every user without any additional logic?

class GetUserUseCase( 
private val userRepository: UserRepository
) {

operator fun invoke(userConfig: UserConfig): Result<User> {
return userRepository.getUser()
}

}

class AddUserUseCase(
private val userRepository: UserRepository
) {

operator fun invoke(user: User) {
return userRepository.addUser(user)
}

}

class DeleteUserUseCase(
private val userRepository: UserRepository
) {

operator fun invoke(user: User) {
return userRepository.deleteUser(user)
}

}

class UpdateUserUseCase(
private val userRepository: UserRepository
) {

operator fun invoke(user: User) {
return userRepository.updateUser(user)
}

}

The number of your one-line use cases can grow exponentially, you will create multiple use cases for a single data type which you can have many. In real world the situation is worse, you can have UseCases such as GetAllUsersUseCase, GetOldUserCase, etc.

These use cases do nothing except act as proxies to the data layer. In some implementations of Clean Architecture, there can also be mappers that convert models back and forth, which worsens the situation further, you are not converting any business rules into action. You don’t resolve any problems; you write additional code to satisfy the rules of clean architecture.

Let’s reflect on it a little bit:

Over time, the number of use cases becomes too large, partly due to the contribution of ‘one-line use cases.’ You can end up with 50, 100, 200, 500, or even more use cases in your project. This large amount of use cases brings problems.

For example, imagine you are working on a new screen and need to display some data already used in the application. There are already written use cases, repositories, and so on that work with that data type. Now, among these hundreds of use cases, you have to find one you can reuse. This task can be either trivial or difficult, depending on various factors in your project.

Do you have a strict naming convention? If yes, your use case might start with “GetMyDataType” or something similar and you can start searching by its name. However, naming is hard, and even with strict conventions, it may not accurately represent its intent.

Do you have a multi-repo architecture? It’s when every team works on different separate projects with their repositories, and all of these projects are included in a main application in the form of libraries. In such cases, the use cases you need might be in another repo, making it even more difficult to find and reuse them. You basically need to search through various projects just to find a use case, and probably you won’t be able to use it because it can be encapsulated from you. Even if it is not encapsulated, if they change something, they can break your code, so it should be encapsulated.

The same with [feature modularization](https://developer.android.com/topic/modularization). Changes in the code of a feature should not affect other features or the application. You want to have high cohesion for your feature modules, so you create your use cases with the internal modifier to be accessible only within its module, thus deliberately making them less reusable.

The main argument for writing them and strictly following the Dependency Rule is: “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.” But how is this in practice?

Let’s check an example: We have a use case that provides us with a list of credit cards in our banking app.

class GetCreditCartsUseCase( 
private val creditCardRepository: CreditCardRepository
) {

operator fun invoke(): Result<List<CreditCard>> {
return creditCardRepository.getCreditCards()
}
}

And we need to display this data in several places across the app.

The day finally comes when we have changes in requirements. We can change the use case without worrying that the changes won’t be applied in other places. Therefore, we can make changes in only one place, and add a new unit test for just one class, and that’s it.

But here’s the trick: the requirements have changed for only one of the screens. On the overview screen, I need to show the most recently acquired credit card instead of all of them. Now, instead of reusing the existing use case, I need to create a new one and modify the affected viewModel. In my experience, this is the case with requirements and use cases most of the time. (Unless you can predict the future, in such you will know what and where can be reused)

Now let’s think about the business requirement for our UseCase. We need to fetch a list of credit cards and show it on the UI. What are the chances that there will be any additional logic that should affect all the places where it’s used? I would bet on “never,” and I bet that if you think about the app you’re currently working on, you’ll find many use cases that can never change just by the nature of these requirements.

Another problem that occurs potentially on every big project is constructor over-injection:

class UserSettingsViewModel( 
private val getUserUserCase: GetUserUserCase,
private val getAllUsersUseCase: GetAllUsersUseCase,
private val addUserUseCase: AddUserUseCase,
private val deleteUserUseCase: DeleteUserUseCase,
private val updateUserUseCase: UpdateUserUseCase,
private val getPremiumUsersUseCase: GetPremiumUsersUseCase,
private val getFiltersUseCase: GetFiltersUseCase,
private val getAppSettingsUseCase: GetAppSettingsUseCase,
private val selectUserUseCase: SelectUserUseCase,
private val selectFilterUseCase: SelectFilterUseCase,
private val updateAppSettingsUseCase: UpdateAppSettingsUseCase
//…so on…
)

Looks familiar isn’t it? Nothing to comment on. (But, to be fair, if for you 10–20–40+ arguments for the constructor are okay, then it's not a problem).

Now, what can we do about this?

Use the Data layer directly
The approach Google recommends:
https://developer.android.com/topic/architecture/domain-layer#data-access-restriction

”However, the potentially significant disadvantage is that it forces you to add use cases even when they are just simple function calls to the data layer, which can add complexity for little benefit.

A good approach is to add use cases only when required. If you find that your UI layer is accessing data through use cases almost exclusively, it may make sense to only access data this way.”

They recommend violating the Dependency Rule and using the data layer directly in the UI layer for some cases, which is also food for thought.

Facade

However, If you want to maintain the separation of concerns and follow the clean architecture layering principles completely without falling into the trap of excessive one-liner use cases, consider using a facade pattern.

class UserFacade(
private val userRepository: UserRepository,
private val userMapper: UserMapper
) {

fun getUser(userConfig: UserConfig): Result<User> {
return userRepository.getUser(userConfig)
}

fun getAllUsers(): Result<List<User>> {
return userRepository.getUsers()
}

fun getAllPremiumUsers(): Result<List<User>> {
return userRepository.getPremiumUsers()
}

fun addUser(user: User): Result<User> {
return userRepository.addUser(user)
}

fun deleteUser(user: User): Result<User> {
return userRepository.deleteUser(user)
}

fun updateUser(user: User): Result<User> {
return userRepository.updateUser(user)
}
}

The facade pattern — makes the subsystem easier to use by encapsulating the complexity behind a single, more cohesive interface. Instead of having multiple one-liner use cases scattered throughout the codebase, a facade consolidates these operations. This reduces the redundancy of having many use cases that essentially do the same thing — interact with the data layer.

You can think about many other options, such as a facade with a generic type that can be reused with any data type, and experiment to find what is working the best for you.

Single Responsibility Principle

Now let’s talk about SRP from SOLID principles. Remember that your UseCase should be a single action, and by its definition should adhere to the SRP.

We have a UseCase responsible for the registration of a new user:

class UserRegistrationUseCase(  
private val userRepository: UserRepository,
private val appThemeRepository: AppThemeRepository,
private val emailService: EmailService,
private val securityService: SecurityService,
private val promotionsService: PromotionsService,
) {

operator fun invoke(userDetails: UserDetails): Result<User> {
if (securityService.weak(password = userDetails.password)) {
return Result.failure(Exception("Password is weak"))
}

val isPromotional = checkPromotionalEligibility(userDetails.email, userDetails.location)
val userSettings = UserSettings("en-US", receiveNewsletters = isPromotional)
val starterPack = if (isPromotional)
promotionsService.getPromotionalStarterPack(userDetails.location)
else promotionsService.getDefaultStarterPack(userDetails.location)

val user = User.fromUserDetails(userDetails, securityService.encryptPassword(userDetails.password), isPromotional, userSettings, starterPack)

userRepository.save(user)
emailService.sendWelcomeEmail(userDetails.email, isPromotional)

if (isPromotional)
appThemeRepository.save(user.id, "dark")
else appThemeRepository.save(user.id, "light")

promotionsService.schedulePersonalizedFollowUps(user.id, user.email, user.isPromotional)
return Result.success(user)
}

private fun checkPromotionalEligibility(email: String, location: String): Boolean {
val isEmailEligible = email.endsWith("@example.com")
val isLocationEligible = location == "USA" // Assume promotional eligibility for USA
return isEmailEligible && isLocationEligible
}
}

If we take a look at this UseCase, does it violate SRP? Probably yes. There is the code that can be extracted into separate use cases, and reused, let's try to do it.

class UserRegistrationFlowUseCase(  
private val saveUserUseCase: SaveUserUseCase,
private val prepareNewUserUseCase: PrepareNewUserUseCase,
private val userFollowUpUseCase: UserFollowUpUseCase,
private val sendWelcomeEmailUseCase: SendWelcomeEmailUseCase,
private val setAppThemeUseCase: SetAppThemeUseCase,
) {

operator fun invoke(userDetails: UserDetails): Result<User> {
val userResult = prepareNewUserUseCase.prepareUser(userDetails)
if (userResult.isError()) {
return userResult
}
val user = userResult.get()

saveUserUseCase(user)
sendWelcomeEmailUseCase(user)
userFollowUpUseCase(user)
setAppThemeUseCase(user)
return Result.success(user)
}
}

class PromotionEligibilityUseCase(
private val emailPromotionEligibilityUseCase: EmailPromotionEligibilityUseCase,
) {

fun checkEligibility(userDetails: UserDetails): Boolean {
val isEmailEligible = emailPromotionEligibilityUseCase(userDetails.email)
val isLocationEligible = userDetails.location == "USA" // Assume promotional eligibility for USA
return isEmailEligible && isLocationEligible
}

}

class EmailPromotionEligibilityUseCase {

operator fun invoke(email: String): Boolean {
val emailRegex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}$"
return email.endsWith("@example.com") && email.matches(emailRegex.toRegex())
}
}

class PrepareNewUserUseCase(
private val securityService: SecurityService,
private val getStarterPackUseCase: GetStarterPackUseCase,
private val promotionEligibilityUseCase: PromotionEligibilityUseCase,
) {

fun prepareUser(userDetails: UserDetails): Result<User> {
if (securityService.weak(password = userDetails.password)) {
return Result.failure(Exception("Password is weak"))
}
val isPromotional = promotionEligibilityUseCase.checkEligibility(userDetails)
val userSettings = UserSettings("en-US", receiveNewsletters = isPromotional)
val starterPack = getStarterPackUseCase(userDetails.location, isPromotional)
val encryptedPassword = securityService.encryptPassword(userDetails.password)
return Result.success(User(
id = 0,
name = userDetails.name,
email = userDetails.email,
password = encryptedPassword,
location = userDetails.location,
isPromotional = isPromotional,
settings = userSettings,
starterPack = starterPack
)
)
}
}

class GetStarterPackUseCase(private val promotionsService: PromotionsService) {

operator fun invoke(location: String, isPromotional: Boolean): StarterPack {
return if (isPromotional)
promotionsService.getPromotionalStarterPack(location)
else
promotionsService.getDefaultStarterPack(location)
}
}

class SaveUserUseCase(private val userRepository: UserRepository) {

operator fun invoke(user: User): User {
return userRepository.save(user)
}
}

class SendWelcomeEmailUseCase(
private val emailService: EmailService
) {

operator fun invoke(user: User) {
emailService.sendWelcomeEmail(user.email, user.isPromotional)
}
}

class SetAppThemeUseCase(
private val appThemeRepository: AppThemeRepository
) {

operator fun invoke(user: User) {
if (user.isPromotional)
appThemeRepository.save(user.id, "dark")
else
appThemeRepository.save(user.id, "light")
}
}

class UserFollowUpUseCase(
private val engagementTracker: EngagementTracker
) {

operator fun invoke(user: User) {
engagementTracker.schedulePersonalizedFollowUps(user.id, user.email, user.isPromotional)
}
}

We created eight new use cases that satisfy the Single Responsibility Principle (SRP). All of them are small, look nice, and can be easily reused. I’m not talking about testing because in both cases we can easily test everything.

However, this approach creates enormous **contextual overhead**. The more functions you have, the more you need to go through to understand what is happening. In this example, you need to jump between nine files to see the full picture. Imagine reading a paragraph in a book, then having to go to the next page to read a certain paragraph, then to the next page again, and then back to the original page to read the next sentence, and finally to the end of the book to read another paragraph, all of this just to understand what is happening in one sentence of a book.

You basically need to keep a tree of functions and what they are doing in your head, reading and remembering what is happening in every use case. Read what a function is doing, go next — read again, go next — read, then undo it go back, — undo it again — till the original use case, and continue with every function that is left. Because all of them are in different files you need to remember all of them.

This is also painful for debugging, where you need to jump between breakpoints in different files, and the data in the debugger is usually shown only for the current class. It’s equally challenging for code reviews, where it’s not always possible to navigate easily between files and functions.

Even with just 2–3–4 use cases, the code is much harder to comprehend. The functionality in the example is simple and easy, but in real projects, it is usually more complex, and names are not always obvious or representative (naming is hard).

In the first approach, the use case has a function with 20–25 lines of code that fit even on the smallest displays. You can read it like a book and see the whole work without overloading yourself with “What was in those three functions I checked 20 seconds ago?” Also, do we need this “reusability” if it is not used in more than one place? Splitting use cases into smaller, more specific components before there is a clear need for reusability is premature optimization. This approach might complicate the architecture unnecessarily, as it introduces more elements to manage and maintain without practical benefits.

The question is, are we trying to solve a problem here, or are we simply abiding by these rules because we think this is the only right way to write code?

For me, it’s much easier to work with and maintain the code when you don’t need to strictly follow all the rules (which were introduced many years ago and didn’t evolve, by the way).

Summary

- Dependency Rule: This rule states that source code dependencies can only point inward, often leading to the creation of use cases that act as proxies to the data layer.

- One-Line Use Cases: Strict adherence to Clean Architecture can result in numerous one-line use cases that do nothing but act as proxies to other layers, leading to an exponential increase in the number of use cases.

- Exponential Growth: The number of use cases can grow rapidly, creating challenges in maintaining and navigating the codebase.

- Complexity and Overhead: Strict adherence to SRP can lead to breaking down a single use case into many smaller use cases, increasing the complexity and contextual overhead of the system.

- Balance: When designing UseCase, consider the trade-offs between maintaining the Single Responsibility Principle (SRP) for theoretical purity and reusability, and creating a practical, maintainable system that minimizes operational overhead.

Thanks for the reading!

--

--