Blog Infos
Author
Published
Topics
,
Published

Photo by Jefferson Santos on Unsplash

 

Another Clean Architecture Medium Article in 2023?

Yes, I know what you’re thinking — another article about Clean Architecture. However, this time, we’ll take a fresh approach. We’ll not only revisit the fundamentals but also delve into powerful concepts that can enhance and simplify (or overcomplicate?) our codebase.

But before we embark on this journey, let us summarize clean architecture to ensure we’re on the same page.

Understanding Clean Architecture: A Modular and Scalable Software Design

Clean Architecture is an architectural pattern introduced by Robert C. Martin, also known as Uncle Bob. Its fundamental principle revolves around the separation of concerns, advocating for a modular and scalable software design that enables easy maintenancetestability, and adaptability.

The key idea behind Clean Architecture is to enforce strict boundaries between different layers of an application, ensuring that each layer has a distinct responsibility and remains independent of others. This separation of concerns allows developers to make changes to one layer without affecting other layers, facilitating codebase maintenance and reducing the risk of introducing bugs.

In the context of Android development, Clean Architecture is commonly organized into three main layers:

  1. Data Layer: The outermost layer responsible for handling data retrieval and storage. It includes the implementation of repositories and data sources that interact with databases, APIs, or other external data providers.
  2. Domain Layer: The middle layer that contains the business logic and use cases of the application. Here, the core business rules and operations are defined independently of any specific framework or technology.
  3. Presentation Layer: The innermost layer responsible for handling the user interface and user interactions. It orchestrates the interaction between the user interface components, ViewModel (or Presenter), and the Domain layer.

Clean Architecture encourages the use of dependency inversion, where dependencies point inwards towards higher-level policies and do not depend on lower-level details. This makes the architecture more adaptable to changes and allows for easy substitution of components.

By adhering to Clean Architecture principles, developers can create maintainabletestable, and scalable applications that are not bound to any particular framework or technology. It empowers teams to embrace changeadapt to new requirements, and future-proof their codebase, making it an essential and timeless approach in modern software development.

Challenges when implementing Clean Architecture

While Clean Architecture provides a robust structure for our projects, the practical implementation of the Repository and UseCase layers can present unique challenges. As we navigate through these layers, we encounter obstacles that affect code maintainability and development efficiency. Let’s explore these challenges and delve into potential solutions to revitalize our approach.

Let us start with UseCase.
class GetUserDataUseCase(
    private val userRepository: UserRepository
) {

    operator fun invoke(): Result<UserData> {
        return userRepository.getUserData()
    }
}

The approach of using a single function as a UseCase has its advantages in terms of simplicityconciseness, and clean separation of concerns. However, it might face limitations in terms of reusability and flexibility.

To further explain the disadvantage of using this Approach:
  1. Boilerplate Code: In this approach, each use case is represented by a separate class. As the number of use cases grows, it can lead to a lot of boilerplate code, making the codebase less concise and harder to manage.
  2. File Organization: With individual use case classes, you may end up with a large number of files, and it might become challenging to organize and navigate them, especially in larger projects.
  3. Dependency Injection: Managing dependencies for each use case class can become cumbersome, especially if multiple dependencies need to be injected.
  4. Limited Reusability: Since each UseCase is tied to a specific repository, reusing the same UseCase for different data sources might require creating separate instances with different dependencies. This could lead to code duplication and reduced code reusability.

The current approach of using individual classes for each UseCase has several disadvantages that impact the codebase’s scalability and maintainability. The presence of boilerplate code and numerous files can make the codebase harder to manage. Additionally, managing dependencies for each UseCase class becomes more complex as the project grows, leading to potential issues in reusability and code organization.

Due to some of these disadvantages, developers might explore alternative solutions that offer more flexibility and maintainability. Google and other experts may explain that the UseCase layer is optional because some projects, particularly smaller ones with less complex domain logic, might not experience significant benefits from the additional layer. In such cases, developers may opt for a simpler architecture, combining the business logic directly within the ViewModel or Presenter.

Moving on to Repositories.
1. Domain Layer (Interfaces)
// UserRepository.kt (Repository Interface)
interface UserRepository {
    suspend fun getUserById(userId: String): User
    suspend fun saveUser(user: User)
}
2. Data Layer (Implementation of the Repository)
// UserDataSource.kt (Data Source Interface)
interface UserDataSource {
    suspend fun getUserById(userId: String): User
    suspend fun saveUser(user: User)
}

// UserRepositoryImpl.kt (Repository Implementation)
class UserRepositoryImpl(
  private val userDataSource: UserDataSource
) : UserRepository {

    override suspend fun getUserById(userId: String): User {
        return userDataSource.getUserById(userId)
    }

    override suspend fun saveUser(user: User) {
        userDataSource.saveUser(user)
    }
}

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

Jobs

There is no problem with this code. The code is simple and we can very understand how things work.

The challenge arises when dealing with multiple data sources. Coordinating data retrieval and storage logic across various sources, such as local database, remote API, or cache, can result in intricate repository implementations. As the application grows, maintaining the traditional repository pattern becomes cumbersome due to the increasing number of interfaces and classes related to the repository. These challenges may lead to code complexity and reduced maintainability over time.

Let’s examine a simplified code example to illustrate spaghetti code in the repository implementation:

class UserRepository {
    
    // Public Method to be used in usecase.
    fun getUserData(): Result<UserData, String> {
        return checkIfSupported()
    }

    private fun checkIfSupported(): Result<UserData, String> {
        // Code to check if the user is supported by the system
        // ...
        val isSupported = true // Replace with actual check result

        return if (isSupported) {
            val userData = fetchUserDataFromAPI()
            checkType(userData)
        } else {
            Result.failure("User not supported by the system.")
        }
    }

    private fun fetchUserDataFromAPI(): UserData {
        var userData = UserData()
        // Code to fetch user data from API
        // ...
        return processData(userData)
    }

    private fun checkType(userData: UserData): Result<UserData, String> {
        // Code to check the type of user data
        // ...
        val deviceType = DeviceType.Type1 // Replace with actual check result

        return when (deviceType) {
            is DeviceType.Type1 -> {
                // Get the needed data for Type1 device
                // ...
                Result.success(userData)
            }
            is DeviceType.Type2 -> {
                val isConnected = true // Replace with actual check result
                if (isConnected) {
                    checkDeviceAvailability(userData)
                } else {
                    Result.failure("Failed to connect to the device.")
                }
            }
        }
    }

    private fun processData(userData: UserData): UserData {
        // Code to process user data (e.g., mapping or filtering)
        // ...
        return userData
    }

    private fun checkDeviceAvailability(
      userData: UserData
    ): Result<UserData, String> {
        // Code to check if the device is available (for type2 device)
        // ...
        val isDeviceAvailable = true // Replace with actual check result

        return if (isDeviceAvailable) {
            // Get the needed data for Type2 device
            // ...
            Result.success(userData)
        } else {            
            Result.failure("Device not available.")
        }
    }
}

sealed class DeviceType {
    object Type1 : DeviceType()
    object Type2 : DeviceType()
}

In the given UserRepository code, the getUserData() method appears to be straightforward at first glance, with its primary purpose being to check if the user is supported. However, the real complexity lies hidden within the private methods it calls. When we review getUserData(), we are unaware of the additional processes and private method calls it entails. To truly understand the entire flow and dependencies of getUserData(), we need to navigate through multiple private methods, such as fetchUserDataFromAPI()checkType(), and checkDeviceAvailability().

This lack of visibility into the complete logic behind a public method can lead to unintended consequences when making changes or removing certain functionalities. For example, if we decide to modify getUserData() and remove a specific step, it may unknowingly affect other public methods, like getAdminData(), that depend on the same underlying private methods.

This lack of clear visibility and understanding of the entire process can hinder code maintainability and make it challenging to introduce new features or refactor existing functionalities without introducing potential issues in other parts of the codebase. To address this concern, we need an approach that provides better transparency and separation of concerns, enabling us to comprehend the complete flow of each public method and its underlying processes at a glance.

Exploring potential solution

Photo by Edi Libedinsky on Unsplash

Initial idea on fixing the challenges encountered in Repository

One of the initial solutions I considered was to create an additional method, checkUserType(userData: UserData), inside the checkType(userData: UserData) function. By doing so, we can separate the user type-specific logic from the main checkType() method. This approach allows us to first verify the device type and then proceed with the appropriate user type validation before checking device availability.

Here’s how the code might look with the suggested modification:

private fun checkType(userData: UserData): Result<UserData, String> {
        // Code to check the type of user data
        // ...
        val deviceType = DeviceType.Type1

        return when (deviceType) {
            is DeviceType.Type1 -> {
                // Get the needed data for Type1 device
                // ...
                Result.success(userData)
            }
            is DeviceType.Type2 -> {
                val isConnected = true // Replace with actual check result
                if (isConnected) {
                    checkUserType(userData)
                } else {
                    Result.failure("Failed to connect to the device.")
                }
            }
        }
    }

private fun checkUserType(userData: UserData): Result<UserData, String> {
    return when (userType) {
            is UserType.Normal -> {
                // Get the needed data for normal users
                // ...
                Result.success(userData)
            }
            is UserType.Admin -> {
                checkDeviceAvailability(userData)
            }
        }
}

While the approach of creating an additional method checkUserType(userData: UserData) inside checkType(userData: UserData) improves separation of concerns to some extent, it does not entirely address the issue of code maintainability and visibility. If the requirements change again, updating the logic can become a daunting task, as we may not be fully aware of all the consequences and dependencies involved. The lack of high-level visibility into the overall flow of getUserData() still remains a challenge.

With the current solution, it’s difficult to grasp the complete picture of getUserData() without delving into each private method, making it harder to track potential side effects and changes required for new requirements. This complexity can lead to errors and make the codebase less maintainable over time.

To tackle this challenge effectively, we need a solution that not only promotes separation of concerns but also provides clear visibility and comprehension of the entire process of each public method, such as getUserData().

Separating it into two different repository: UserRepository and AdminRepository

Another solution to address the challenges in the repository and improve code maintainability is to separate the functionality into two distinct repositories: UserRepository and AdminRepository. By doing so, we can establish a clearer boundary between the different responsibilities and operations specific to each user type. Let’s take a look at how this could be implemented in code:

interface UserRepository {
    fun getUserData(): Result<UserData, String>
}

class DefaultUserRepository : UserRepository {
    override fun getUserData(): Result<UserData, String> {
        // Code specific to retrieving user data
        // ...
        return Result.success(userData)
    }
}

interface AdminRepository {
    fun getAdminData(): Result<AdminData, String>
}

class DefaultAdminRepository : AdminRepository {
    override fun getAdminData(): Result<AdminData, String> {
        // Code specific to retrieving admin data
        // ...
        return Result.success(adminData)
    }
}

In this implementation, we have separated the UserRepository and AdminRepository interfaces, and each is implemented by its respective default repository class (DefaultUserRepository and DefaultAdminRepository). Now, instead of having a single repository class with complex and intertwined logic, we have two focused repositories, each catering to a specific user type.

The advantage of this approach is that it promotes better code organization and separation of concerns. The UserRepository and AdminRepository now encapsulate the operations related to their respective user types, making the codebase more modular and easier to maintain. Changes or additions to one repository are less likely to impact the other, reducing the risk of unintended side effects.

However, despite this improvement, the high-level visibility issue still persists. While we have separated the responsibilities at a lower level, we may still lack a comprehensive understanding of the entire process and flow of public methods, such as getUserData(). To fully comprehend what’s happening, we might still need to navigate through the private methods inside each repository, making it difficult to gain an overview of the entire operation.

To achieve a higher level of visibility, we need to explore more comprehensive approaches.

Combining Methods for Improved Code Structure

In our pursuit of addressing the challenges, we stumbled upon a promising solution that can greatly simplify our codebase. The concept revolves around combining private methods within the public method itself, creating a more cohesive and readable implementation. Let’s explore how this approach can enhance the maintainability and readability of our application.

class UserRepository {

    // Public method to be used in use cases.
    fun getUserData(): Result<UserData, String> {
        val isSupported = checkIfSupported()
        if (!isSupported) {
            return Result.failure("User not supported by the system.")
        }

        // Consolidate method calls inside getUserData()
        val userData = fetchUserDataFromAPI()
        val processedData = processData(userData)
        val userType = checkType(processedData)

        // More user-specific logic here...
        // ...

        return Result.success(processedData)
    }

    // Public method for admin data retrieval.
    fun getAdminData(): Result<UserData, String> {
        val isSupported = checkIfSupported()
        if (!isSupported) {
            return Result.failure("User not supported by the system.")
        }

        // Calling checkDeviceAvailability only for admin data retrieval.
        val userData = fetchUserDataFromAPI()
        val processedData = processData(userData)
        val userType = checkType(processedData)
        val isConnected = connectToDevice()
        val isDeviceAvailable = checkDeviceAvailability()

        // More admin-specific logic here...
        // ...

        return Result.success(processedData)
    }

    // Other private methods remain unchanged
}

By combining private methods directly within the public methods getUserData() and getAdminData(), we achieve a more concise and cohesive code structure. Developers can now easily understand the high-level flow of data retrieval and processing without the need to navigate through multiple private methods. The codebase becomes more maintainable and less prone to errors, facilitating a better development experience.

This approach embraces functional programming principles by treating functions as first-class citizens. Through this paradigm, we can effortlessly compose and combine methods, resulting in cleaner and more expressive code. The ability to consolidate logic within the public methods not only improves code readability but also opens doors to further functional programming enhancements in our Clean Architecture implementation.

Let’s continue exploring these possibilities to unleash the full potential of our application in the next article.

Photo by Daniel Hering on Unsplash

This article was previously published on proandrdoiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
Hi, today I come to you with a quick tip on how to update…
READ MORE
blog
Automation is a key point of Software Testing once it make possible to reproduce…
READ MORE
blog
Drag and Drop reordering in Recyclerview can be achieved with ItemTouchHelper (checkout implementation reference).…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu