Skip to content

KotlinLastCrusade1 ‐ Branch main

Alexandre Bianchi edited this page Nov 10, 2024 · 4 revisions

Technologies and Tools

  • Retrofit: For network requests to the GitHub API.
  • Koin: For dependency injection, allowing modular and decoupled components.
  • Navigation: For managing screen navigation.
  • XML: For UI layout.
  • Clean Architecture:
    • Divides the application into distinct layers — Data, Domain, and Presentation, using the patterns below:
      • Repository/RepositoryImpl
      • Mappers
      • Use-case
  • Unit Tests: For testing critical application logic.
  • LiveData: For observing and updating the UI with reactive data.

SOLID and architecture

This project retrieves and displays GitHub user information by interacting with the GitHub API, allowing users to see a list of users, details of a specific user, and repositories associated with that user. The implementation is modular and adheres to SOLID principles and Clean Architecture to ensure scalability, maintainability, and testability.

Architecture Overview and Application of SOLID Principles

The project is structured using Clean Architecture, separating code into the following layers:

  1. Data Layer
  2. Domain Layer
  3. Presentation Layer

Each layer is designed to depend on abstractions rather than concrete implementations, in line with SOLID principles, ensuring a clear and maintainable codebase.

SOLID Principles in Action

  1. Single Responsibility Principle (SRP)

    Each class has one responsibility:

    • Data Transfer Objects (DTOs), such as UserDto and RepoDto, are responsible only for holding data returned from the API.
    • Repositories (UserRepositoryImpl and RepoRepositoryImpl) are responsible solely for fetching data from the API and converting it to domain models.
    • Mappers (UserMapper and RepoMapper) are dedicated to converting between DTO and domain models (User, Repo).
    • Use Cases (GetUsersUseCase, GetUserDetailsUseCase, and GetUserReposUseCase) encapsulate specific business logic for each operation, providing a single point of truth for their functionality.

    By following SRP, each class is focused on a single function, making the codebase easier to understand, maintain, and modify.

  2. Open/Closed Principle (OCP)

    The project is designed to be open for extension but closed for modification:

    • Repositories are implemented via interfaces (UserRepository and RepoRepository), allowing new implementations without altering existing code.
    • Mappers are abstracted in the BaseMapper interface, allowing new mappers for different model conversions without changing the mapper logic for existing models.
    • New use cases can be added by creating additional UseCase classes that use the repository interfaces, without modifying existing logic.

    This structure facilitates adding new features, like fetching starred repositories, by simply adding new methods or implementations rather than modifying core classes.

  3. Liskov Substitution Principle (LSP)

    The architecture relies on interfaces that can be implemented and substituted without affecting the rest of the code:

    • UserRepository and RepoRepository are abstractions that can be replaced with any implementation conforming to the interface, such as a mock implementation for testing.
    • GetUserReposUseCase or GetUserDetailsUseCase can operate with any implementation of UserRepository or RepoRepository, as long as they adhere to the interface's contract.

    By applying LSP, the application remains robust and flexible, allowing implementations to be swapped without disrupting dependent components.

  4. Interface Segregation Principle (ISP)

    Interfaces are designed to be specific and avoid unnecessary dependencies:

    • Repository Interfaces (UserRepository and RepoRepository) only expose the methods needed for user and repository management, keeping the interface clean and easy to use.
    • Each Use Case (e.g., GetUserDetailsUseCase, GetUserReposUseCase) is highly specialized, operating only with the methods it requires from the repository interface.

    By segregating interfaces, the application minimizes unused dependencies, reducing complexity and making the codebase easier to maintain.

  5. Dependency Inversion Principle (DIP)

    High-level modules do not depend on low-level modules; they both depend on abstractions:

    • Repositories depend on GitHubApi, which is an abstract interface for API calls and injected via Koin, keeping dependencies flexible.
    • Use Cases depend on UserRepository and RepoRepository interfaces rather than concrete implementations, which are injected at runtime by Koin.
    • The Presentation Layer (ViewModels) depends on abstractions in the Domain Layer (Use Cases) rather than concrete implementations, ensuring the ViewModel is not tightly coupled to a specific data source.

    DIP allows the application to inject dependencies at runtime, making the system modular and testable.


Code Structure and Flow

The code structure follows a layered architecture, where each layer has specific responsibilities and depends only on abstractions. Below is a breakdown of how data flows through each layer, with SOLID principles applied in each step.

Data Layer

  • GitHubApi: Interface that defines API endpoints using Retrofit.
  • RepoRepositoryImpl and UserRepositoryImpl: Implementations of repository interfaces, responsible for data fetching and conversion.

Domain Layer

  • Use Cases: Encapsulate business logic in single classes, each with a specific function:
    • GetUsersUseCase for fetching a list of users.
    • GetUserDetailsUseCase for fetching details of a specific user.
    • GetUserReposUseCase for fetching repositories of a user.

Presentation Layer

  • ViewModels: Rely on Use Cases to get data and observe it via LiveData, providing the data to the UI.

Architecture strategies

Dispatcher Responsibility in Coroutine Design

This principle relates to responsibility for handling coroutines and Dispatchers within an app, especially when working with Kotlin's coroutines and suspend functions. The main idea is to avoid making the classes that call suspend functions responsible for choosing the appropriate Dispatcher (e.g., Dispatchers.IO for network or database operations, Dispatchers.Main for UI updates). Instead, this responsibility should be encapsulated within the class that actually performs the work—such as a repository class. This separation simplifies the calling code and increases scalability and maintainability.

Key Points of This Principle
  1. Separation of Concerns

    • When using coroutines, a Dispatcher determines which thread the coroutine will run on.
    • The calling code (such as a ViewModel or Use Case) should not need to know or care about which Dispatcher to use; it should simply call the function and handle the result.
    • Example: Instead of specifying a Dispatcher in the ViewModel, the Repository implementation is responsible for running a network call on Dispatchers.IO.
  2. Responsibility of the Class Doing the Work

    • The Repository (or any class performing the work) is closer to the actual operation being executed and is, therefore, the best place to decide on the Dispatcher.
    • This approach reduces coupling between layers. If the repository class controls the Dispatcher, changes to the type of work (e.g., switching to a new networking library) don’t require changes in the ViewModel or Use Case.
  3. Simpler and More Readable Calling Code

    • The caller can simply use the suspend function without worrying about how it’s executed behind the scenes.
    • Example:
      class MyViewModel(private val repository: RepoRepository) : ViewModel() {
          fun fetchUserData(username: String) {
              viewModelScope.launch {
                  val userRepos = repository.getUserRepos(username)
                  // The ViewModel doesn’t need to know which Dispatcher is used inside getUserRepos.
              }
          }
      }
    • Here, fetchUserData simply calls repository.getUserRepos(), without managing which Dispatcher to use. The Repository internally decides to use Dispatchers.IO.
  4. Scalability and Reusability

    • When Dispatcher logic is confined to the repository or data layer, it becomes easier to reuse these functions across the app, regardless of where they’re called from.
    • This pattern is especially useful in large applications where multiple classes might call the same suspend functions. Changes to the threading behavior can be managed within a single repository class without needing updates across the codebase.

Example Implementation

In the code example, the repository implementation (UserRepositoryImpl and RepoRepositoryImpl) takes responsibility for the Dispatcher:

class UserRepositoryImpl(
    private val api: GitHubApi,
    private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) : UserRepository {
    override suspend fun getUsers(): List<User> = withContext(defaultDispatcher) { 
        api.getUsers().toDomainList() 
    }

    override suspend fun getUserDetails(username: String): User =
        withContext(defaultDispatcher) { 
            api.getUserDetails(username).toDomain() 
        }
}

Here, defaultDispatcher (in this case, Dispatchers.Default) is managed within the repository, ensuring the correct thread is used for this specific type of work. This pattern encapsulates the decision of which Dispatcher to use, keeping the calling code simple.

Benefits of This Pattern
  • Easier Testing: You can mock the defaultDispatcher for tests, making it easier to test in isolation.
  • Reduced Complexity for Callers: The ViewModel or Use Case does not have to worry about which Dispatcher is appropriate.
  • Improved Scalability: Any future changes to the Dispatcher strategy (e.g., if more computation-intensive work is added) only need to happen within the repository.

In summary, this pattern delegates the choice of Dispatcher to the class that knows best about the work being done. This separation promotes clean, maintainable, and testable code while following best practices for coroutine usage.