-
Notifications
You must be signed in to change notification settings - Fork 0
KotlinLastCrusade1 ‐ Branch flow
- Link: https://github.com/Crusade4Code/kotlinlastcrusade1-xml-koin-mapper-usecase
- Description: This project uses Github API to get information from Users and Repos in a user-details screen. The proposal is to use Clean Architecture concepts inside Repositories, through Repository Interfaces, Mappers and UseCases. Also to use Koin as Dependency Injection. Unit Tests. And some important concepts like Dispatcher Responsibility in Coroutine Design.
- 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
- Divides the application into distinct layers — Data, Domain, and Presentation, using the patterns below:
- Unit Tests: For testing critical application logic.
- Flow: For asynchronous and reactive data streams, enabling non-blocking and continuous data collection and manipulation.
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.
The project is structured using Clean Architecture, separating code into the following layers:
- Data Layer
- Domain Layer
- 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.
-
Single Responsibility Principle (SRP)
Each class has one responsibility:
-
Data Transfer Objects (DTOs), such as
UserDtoandRepoDto, are responsible only for holding data returned from the API. -
Repositories (
UserRepositoryImplandRepoRepositoryImpl) are responsible solely for fetching data from the API and converting it to domain models. -
Mappers (
UserMapperandRepoMapper) are dedicated to converting betweenDTOand domain models (User,Repo). -
Use Cases (
GetUsersUseCase,GetUserDetailsUseCase, andGetUserReposUseCase) 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.
-
Data Transfer Objects (DTOs), such as
-
Open/Closed Principle (OCP)
The project is designed to be open for extension but closed for modification:
-
Repositories are implemented via interfaces (
UserRepositoryandRepoRepository), allowing new implementations without altering existing code. -
Mappers are abstracted in the
BaseMapperinterface, allowing new mappers for different model conversions without changing the mapper logic for existing models. - New use cases can be added by creating additional
UseCaseclasses 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.
-
Repositories are implemented via interfaces (
-
Liskov Substitution Principle (LSP)
The architecture relies on interfaces that can be implemented and substituted without affecting the rest of the code:
-
UserRepositoryandRepoRepositoryare abstractions that can be replaced with any implementation conforming to the interface, such as a mock implementation for testing. -
GetUserReposUseCaseorGetUserDetailsUseCasecan operate with any implementation ofUserRepositoryorRepoRepository, 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.
-
-
Interface Segregation Principle (ISP)
Interfaces are designed to be specific and avoid unnecessary dependencies:
-
Repository Interfaces (
UserRepositoryandRepoRepository) 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.
-
Repository Interfaces (
-
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
UserRepositoryandRepoRepositoryinterfaces 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.
-
Repositories depend on
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.
- GitHubApi: Interface that defines API endpoints using Retrofit.
- RepoRepositoryImpl and UserRepositoryImpl: Implementations of repository interfaces, responsible for data fetching and conversion.
-
Use Cases: Encapsulate business logic in single classes, each with a specific function:
-
GetUsersUseCasefor fetching a list of users. -
GetUserDetailsUseCasefor fetching details of a specific user. -
GetUserReposUseCasefor fetching repositories of a user.
-
- ViewModels: Rely on Use Cases to get data as Flow streams and collect them in a coroutine scope. The ViewModel processes the Flow data and emits it to the UI using StateFlow for continuous observation and updates.
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.
-
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
Dispatcherin the ViewModel, theRepositoryimplementation is responsible for running a network call onDispatchers.IO.
-
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.
- The
-
Simpler and More Readable Calling Code
- The caller can simply use the
suspendfunction 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,
fetchUserDatasimply callsrepository.getUserRepos(), without managing which Dispatcher to use. TheRepositoryinternally decides to useDispatchers.IO.
- The caller can simply use the
-
Scalability and Reusability
- When
Dispatcherlogic 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
suspendfunctions. Changes to the threading behavior can be managed within a single repository class without needing updates across the codebase.
- When
In the code example, the repository implementation (UserRepositoryImpl and RepoRepositoryImpl) takes responsibility for the Dispatcher:
class UserRepositoryImpl(
private val api: GitHubApi,
private val dispatchersProvider: DispatchersProvider,
) : UserRepository {
// Private method to get users
private suspend fun getUsers(): List<User> =
withContext(dispatchersProvider.default) { api.getUsers().toDomainList() }
// New method to return Flow with all users
override fun getUsersFlow(): Flow<List<User>> = flow {
// Emits the list of users obtained from API
val userList = getUsers()
emit(userList)
}
// Private method to get user details from the API
private suspend fun fetchUserDetails(username: String): User =
withContext(dispatchersProvider.default) { api.getUserDetails(username).toDomain() }
// Public method to return user details as Flow
override fun getUserDetailsFlow(username: String): Flow<User> = flow {
// Emit the user details retrieved from the API
val userDetails = fetchUserDetails(username) // Call the private method
emit(userDetails) // Emit the user details
}
}Here, defaultDispatcher (in this case, dispatchersProvider.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.
-
Easier Testing: You can mock the
defaultDispatcherfor 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.