-
Notifications
You must be signed in to change notification settings - Fork 0
KotlinLastCrusade1 ‐ Branch main
- 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.
- LiveData: For observing and updating the UI with reactive data.
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
UserDto
andRepoDto
, are responsible only for holding data returned from the API. -
Repositories (
UserRepositoryImpl
andRepoRepositoryImpl
) are responsible solely for fetching data from the API and converting it to domain models. -
Mappers (
UserMapper
andRepoMapper
) are dedicated to converting betweenDTO
and 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 (
UserRepository
andRepoRepository
), 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.
-
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:
-
UserRepository
andRepoRepository
are abstractions that can be replaced with any implementation conforming to the interface, such as a mock implementation for testing. -
GetUserReposUseCase
orGetUserDetailsUseCase
can operate with any implementation ofUserRepository
orRepoRepository
, 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 (
UserRepository
andRepoRepository
) 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
UserRepository
andRepoRepository
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.
-
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:
-
GetUsersUseCase
for fetching a list of users. -
GetUserDetailsUseCase
for fetching details of a specific user. -
GetUserReposUseCase
for fetching repositories of a user.
-
- ViewModels: Rely on Use Cases to get data and observe it via LiveData, providing the data to the UI.
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
Dispatcher
in the ViewModel, theRepository
implementation 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
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 callsrepository.getUserRepos()
, without managing which Dispatcher to use. TheRepository
internally decides to useDispatchers.IO
.
- The caller can simply use the
-
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.
- When
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.
-
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.