This application demonstrates a modern Android application using the Clean Architecture approach, best practices, design patterns, SOLID principles, and other notable technologies. The application uses Kotlin, Retrofit for networking, Room for local data persistence, Coroutines for background tasks, Flow for data streams, and Dagger Hilt for dependency injection. It adheres to the MVVM design pattern and utilizes the repository pattern for data management.
Clean Architecture separates code into layers, making the codebase easier to manage, test, and scale. It isolates changes in an application, leading to better maintainability.
The Core or Domain Layer is the central part of the architecture and is completely independent. It contains business logic, use cases, and entities (domain models), and defines rules for how the data can be accessed and modified. It doesn't depend on any other layer, ensuring that business logic is decoupled from the infrastructure, making it platform-independent and ensuring that the rules of the app can be tested without any dependencies.
The Core Layer includes:
-
Domain Models: These are simple data classes that represent the objects in the app.
package com.dnnsgnzls.modern.domain.model data class Game( val id: Long, val slug: String, val name: String, val released: String?, val backgroundImage: String?, val rating: Double? )
-
Use Cases: These classes contain the business logic of the app. Each use case represents a single action that the app performs.
package com.dnnsgnzls.modern.domain.usecases import com.dnnsgnzls.modern.domain.model.Game import com.dnnsgnzls.modern.framework.utils.Response import kotlinx.coroutines.flow.Flow interface GetGameUseCase { operator fun invoke(id: String, tryQueryingFromCache: Boolean): Flow<Response<Game>> }
-
Repository Interfaces: These interfaces are defined in the domain layer but implemented in the data layer. They represent the contracts for the data layer to provide data to the domain layer.
package com.dnnsgnzls.modern.domain.repository import com.dnnsgnzls.modern.domain.model.Game import com.dnnsgnzls.modern.domain.model.Review import com.dnnsgnzls.modern.framework.utils.Response import kotlinx.coroutines.flow.Flow interface GamesRepository { fun getGame(id: String, tryQueryingFromCache: Boolean): Flow<Response<Game>> ... }
The Core (Domain) Layer ensures that the business logic of the application is not affected by the changes in the outer layers like the UI or database.
The Data layer is responsible for providing the required data to the application by implementing the operations defined in the repository interfaces of the Domain layer. It's the actual layer that decides how to fetch the data - from a network, a database, or other data sources.
In the provided code, the Data layer includes:
-
Data Transfer Objects (DTOs): These are objects that are used to transfer data between processes. They are often used in conjunction with web services, where they can be used to encapsulate the data being transferred over the network.
package com.dnnsgnzls.modern.data.api data class GameDto( val id: Long, val slug: String, val name: String, val released: String?, @SerializedName("background_image") val backgroundImage: String?, val rating: Double?, )
-
Database Entities: These are objects that represent tables in the database. They are used to read from and write to the database.
package com.dnnsgnzls.modern.data.db @Entity(tableName = GAME_TABLE) data class GameEntity( @PrimaryKey val id: Long, val slug: String, val name: String, val released: String?, @ColumnInfo("background_image") val backgroundImage: String?, val rating: Double? )
-
Repository Implementations: These classes implement the repository interfaces from the Domain layer. They use services (like network calls) and DAOs to fetch the data.
package com.dnnsgnzls.modern.data.repository class GamesRepositoryImpl( private val rawgApi: RawgApi, private val gameDao: GameDao, private val reviewDao: ReviewDao, private val dispatcher: CoroutineDispatcher ) : GamesRepository { override fun getGame(id: String, tryQueryingFromCache: Boolean): Flow<Response<Game>> = flow { emit(Response.Loading) ... } ... }
The Data layer decides how to fetch the data needed by the application, but it doesn't know what the data will be used for. This makes the Data layer independent of the UI and allows the UI to be modified without affecting the data fetching logic.
The Data layer in this project is encapsulated within the "framework" package. This package contains classes that interact with Android and other third-party frameworks. It includes components for network communication and local database operations, such as ApiService
, RawgApi
, DatabaseService
, and GameDao
.
// Sample of a DAO class
@Dao
interface GameDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(gameEntity: GameEntity)
// Other methods...
}
// Sample of a Retrofit service interface
interface RawgApi {
@GET("/api/games")
suspend fun games(
@Query("search") searchQuery: String,
@Query("page") page: Int,
@Query("search_precise") searchPrecise: Boolean = true
): GamesDto
// Other methods...
}
These classes form the Data layer because they are directly involved in handling, manipulating, and storing data. They provide the necessary data to the application and implement the operations defined in the repository interfaces from the Domain layer. Despite their physical organization in the project, their role aligns with the principles of the Data layer in Clean Architecture.
Certainly, let's delve into the Presentation layer.
The Presentation layer is responsible for handling all UI and user interactions. In an MVVM (Model-View-ViewModel) pattern, it consists of the Views (Activities, Fragments) and the ViewModels. The ViewModels interact with the Domain or Data layer and provide the data to the UI components.
In the provided code, the Presentation layer includes the ViewModel:
package com.dnnsgnzls.modern.presentation.viewmodels
@HiltViewModel
class GamesViewModel @Inject constructor(
private val gamesUseCases: GamesUseCases
) : ViewModel() {
...
}
Here, the ViewModel is using the use cases from the Domain layer to fetch the data and provide it to the UI. The ViewModel doesn't know how and where the data is fetched. It only knows that it can invoke a method from the use case and get the data. This makes the ViewModel independent of the data source.
MVVM is a software architectural pattern that facilitates the separation of the development of the graphical user interface from the business logic. It separates the code into three layers: Model, View, and ViewModel.
In this app, GamesViewModel
represents the ViewModel layer.
@HiltViewModel
class GamesViewModel @Inject constructor(
private val gamesUseCases: GamesUseCases
) : ViewModel() {
...
}
In the code, the ViewModel uses Kotlin Coroutines and Kotlin Flow to handle asynchronous operations and stream data to the UI. It also uses StateFlow
and SharedFlow
to hold the current state and handle side effects like showing a Snackbar message.
Moreover, the ViewModel uses the Dependency Injection (DI) framework Hilt to get its dependencies. Dependency Injection allows us to define how objects are created and provide dependencies to classes instead of having the classes create the dependencies themselves. This makes our classes more testable and our code more maintainable.
The ViewModel is lifecycle-aware, meaning it can handle lifecycle events of its owner (Activity, Fragment). This ensures the data is preserved during configuration changes like screen rotation.
The ViewModel updates the UI by exposing data as a stream of updates via Kotlin Flow. The UI components (Activities, Fragments) observe these flows and update the UI accordingly. The separation of responsibilities into different layers, each having its own role, is what makes Clean Architecture powerful and flexible.
Dependency Injection (DI) is a design pattern that helps to create loosely coupled and testable code. It's a way to implement the Inversion of Control (IoC) principle, which means that the control of creating dependent objects is shifted from the class itself to another class (often a framework or a container).
In the project, it is using Hilt, a dependency injection library for Android that reduces the boilerplate of doing manual dependency injection in the project. Hilt simplifies DI in by providing containers for every Android class in and managing their lifecycles automatically.
For example, in the RepositoryModule
:
@Module
@InstallIn(SingletonComponent::class)
object RepositoryModule {
...
@Singleton
@Provides
fun provideGamesRepository(
rawgApi: RawgApi,
gameDao: GameDao,
reviewDao: ReviewDao,
coroutineDispatcher: CoroutineDispatcher
): GamesRepository {
return GamesRepositoryImpl(rawgApi, gameDao, reviewDao, coroutineDispatcher)
}
}
In this module, it is providing the dependencies that the application needs. These dependencies are then injected into the classes by Hilt. This makes the code more readable, maintainable, and easier to test. For instance, the GamesRepository
is provided by Hilt in UseCasesModule
:
@Module
@InstallIn(SingletonComponent::class)
object UseCasesModule {
@Singleton
@Provides
fun provideGamesUseCases(gamesRepository: GamesRepository): GamesUseCases {
return GamesUseCases(
GetGameUseCaseImpl(gamesRepository),
GetGamesUseCaseImpl(gamesRepository),
//...
)
}
}
With Hilt, you can inject dependencies right into your classes, and Hilt takes care of providing the right dependencies whenever required:
@HiltViewModel
class GamesViewModel @Inject constructor(
private val gamesUseCases: GamesUseCases
) : ViewModel() {
//...
}
In this example, the GamesViewModel
class is requesting GamesUseCases
in its constructor. Hilt will automatically inject the right implementation of GamesUseCases
when creating an instance of GamesViewModel
. This inversion of control makes the code more modular and easier to test and maintain.
SOLID is an acronym that represents five principles of object-oriented programming and design. These principles, when combined together, make it easier to avoid code smells, easily refactor code, and are also a part of the agile or adaptive software development.
This principle states that a class should have only one reason to change. In other words, a class should only have one job or responsibility.
In the given code, each class has a single responsibility:
class GamesRepositoryImpl(
private val rawgApi: RawgApi,
private val gameDao: GameDao,
private val reviewDao: ReviewDao,
private val dispatcher: CoroutineDispatcher
) : GamesRepository {
//...
}
The GamesRepositoryImpl
class has the responsibility to provide data. It doesn't care about where the data comes from or how it's fetched.
According to this principle, software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.
In the given code, the use of interfaces allows us to extend functionality without modifying existing code. Here's an example:
interface GamesRepository {
fun getGame(id: String, tryQueryingFromCache: Boolean): Flow<Response<Game>>
//...
}
The GamesRepository
interface can be implemented by any class, and those classes can provide their own implementation without changing the interface itself.
This principle states that in an object-oriented program, if class S is a subtype of class T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of that program.
Let's take the GamesUseCases
class as an example. This class is instantiated with a number of different use case implementation objects, all of which implement different interfaces. Here's the class definition:
class GamesUseCases(
val getGameUseCase: GetGameUseCase,
val getGamesUseCase: GetGamesUseCase,
//...
)
Each of these use cases is defined by an interface, such as GetGameUseCase
, and then implemented in a specific class, such as GetGameUseCaseImpl
. This means that the GamesUseCases
class is not dependent on the specific implementation, but rather the interface.
Now, let's consider that we have another implementation of GetGameUseCase
:
class GetGameUseCaseAlternativeImpl(
private val gamesRepository: GamesRepository
): GetGameUseCase {
override fun invoke(id: String, tryQueryingFromCache: Boolean): Flow<Response<Game>> {
// An alternative implementation
}
}
According to the Liskov Substitution Principle, we can now substitute the GetGameUseCaseImpl
with GetGameUseCaseAlternativeImpl
in the GamesUseCases
class, without altering the behavior of the GamesUseCases
class:
@Module
@InstallIn(SingletonComponent::class)
object UseCasesModule {
@Singleton
@Provides
fun provideGamesUseCases(gamesRepository: GamesRepository): GamesUseCases {
return GamesUseCases(
GetGameUseCaseAlternativeImpl(gamesRepository),
GetGamesUseCaseImpl(gamesRepository),
//...
)
}
}
Because we've adhered to the Liskov Substitution Principle, we can easily swap out implementations like this, making our code more flexible and maintainable. This is very useful when we need to change the behavior of our system, for instance when writing tests or adding new features.
This principle states that no client should be forced to depend on interfaces they do not use. In other words, having many client-specific interfaces is better than one general-purpose interface.
In the given code, ISP is followed by defining specific use cases for operations:
interface GetGameUseCase {
operator fun invoke(id: String, tryQueryingFromCache: Boolean): Flow<Response<Game>>
}
This principle states that high-level modules should not depend on low-level modules; both should depend on abstractions. Also, abstractions should not depend on details; details should depend on abstractions.
In the given code, DIP is followed by using Hilt for dependency injection, which allows us to invert the control of dependencies. For example, GamesRepositoryImpl
doesn't create instances of RawgApi
, GameDao
, or ReviewDao
. Instead, it gets them as constructor parameters:
class GamesRepositoryImpl(
private val rawgApi: RawgApi,
private val gameDao: GameDao,
private val reviewDao: ReviewDao,
private val dispatcher: CoroutineDispatcher
) : GamesRepository {
//...
}
These principles help in making a system more flexible, maintainable, and understandable.s
Room is an ORM, which stands for "Object Relational Mapping". It allows easy interaction with the SQLite database by using annotations. For example, the GameDao
interface is an abstraction for accessing the game data in the database.
@Dao
interface GameDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(gameEntity: GameEntity)
@Query("SELECT * FROM $GAME_TABLE WHERE id=:gameId")
fun get(gameId: Long): Flow<GameEntity?>
...
}
Retrofit is a type-safe HTTP client for Android and Java. It makes it easy to consume JSON or XML data which is parsed into Plain Old Java Objects (POJOs). For example, the RawgApi
interface is an abstraction for the API endpoints.
interface RawgApi {
@GET("/api/games")
suspend fun games(
@Query("search") searchQuery: String,
@Query("page") page: Int,
@Query("search_precise") searchPrecise: Boolean = true
): GamesDto
...
}
Kotlin Coroutines are a concurrency design pattern that simplifies code that executes asynchronously. Coroutines contribute to writing cleaner and more concise app code by reducing boilerplate and edge cases.
Kotlin Flow is a type that can emit multiple values sequentially, in contrast to suspend functions that return only a single value. Flow is a component of coroutines and supports asynchronous operations just like coroutines do. It allows handling of streams of data that produce values over time.
In the project, both Kotlin Coroutines and Flow are extensively used to manage background tasks and handle data streams.
Consider the following examples of fetching games from the repository:
override fun getGames(searchQuery: String, page: Int): Flow<Response<List<Game>>> = flow {
emit(Response.Loading)
val gamesDto = rawgApi.games(searchQuery, page)
val games = mapGamesFromDto(gamesDto)
emit(Response.Success(games.results))
}.catchException().flowOn(dispatcher)
override fun getFavouriteGames(): Flow<Response<List<Game>>> = flow {
emit(Response.Loading)
gameDao.getAll().collect { gameEntityList ->
val games = gameEntityList.map { it.toDomainModel() }
emit(Response.Success(games))
}
}.catchException().flowOn(dispatcher)
Here, a coroutine builder flow
is used to build a Flow that emits a loading state, fetches games from the network, maps the DTO to domain model, and then emits the success state with the games data. The catchException()
and flowOn(dispatcher)
are extension functions on Flow, which handle exceptions and specify the dispatcher on which this Flow should be collected, respectively.
Furthermore, in the ViewModel, coroutines are used to collect these Flows:
private val _game = MutableStateFlow<Response<Game>>(Response.Loading)
val game: StateFlow<Response<Game>>
get() = _game
...
suspend fun fetchSingleGame(gameId: String) {
gamesUseCases.getGameUseCase(gameId, tryQueryingFromCache = true).collect { response ->
_game.value = response
}
}
In this example, the ViewModel is collecting the Flow from the use case within a coroutine. The collect
function is a terminal operator that triggers the collection of the flow. As the Flow is being collected, each emitted value updates the _game
MutableStateFlow, which is then observed by the UI. This approach allows for the management of asynchronous tasks and ensures the UI is kept up-to-date with the latest data.
Jetpack Compose is Android's modern toolkit for building native UI. It simplifies and accelerates UI development on Android by allowing developers to write declarative UIs, reduce boilerplate code, and avoid "fragment hell".
Let's go through some of the Jetpack Compose and other best practices applied in your code:
This annotation is part of the Hilt library, which is a dependency injection library built on top of Dagger. By annotating MainActivity
with @AndroidEntryPoint
, Hilt can provide dependencies to your MainActivity
. This is an example of the Dependency Injection design pattern, which promotes code decoupling and increases the testability of your app.
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
//...
}
hiltViewModel()
is a Jetpack Compose function that retrieves the ViewModel from the current NavBackStackEntry
as specified by the ViewModelStoreOwner
, and if not present, creates it. This is part of the MVVM architectural pattern, where the ViewModel acts as an intermediary between the View and the Model.
val gamesViewModel: GamesViewModel = hiltViewModel()
Navigation is handled in Jetpack Compose through the NavController
class. The rememberNavController()
function creates and remembers a NavController
.
val navController = rememberNavController()
LaunchedEffect
is a Jetpack Compose function used to perform side-effects in a composable. A side-effect is a block of code that's executed from a composable, that interacts with the rest of your app.
LaunchedEffect(Unit) {
gamesViewModel.getFavouriteGameIds()
}
In the above snippet, LaunchedEffect
is used to fetch the favourite game IDs once when the GameListScreen
composable is first launched.
State management in Jetpack Compose is done using mutable state. Any composable that reads the value of a mutable state will be recomposed whenever the value changes. In the following code, gamesViewModel.favouriteGameIds.collectAsStateWithLifecycle()
is a state flow of favourite game IDs from the ViewModel that's collected and converted into state readable by composables.
val favouriteGameIdsState: Response<List<Long>> by gamesViewModel.favouriteGameIds.collectAsStateWithLifecycle()
In Jetpack Compose, user interactions are handled through lambda functions that are passed to the composables that require them. In the following code, gamesViewModel::inputQueryChanged
is a function reference that's passed to the GamesSearchBar
composable.
GamesSearchBar(
queryTextState = queryTextState,
onQueryTextChanged = gamesViewModel::inputQueryChanged
)