This is a simple app resolving a simple feature: An App that lists the most recent stories from WordPress.com Discover that contains featured images. The purpose of this code is not only displaying a UI but also providing a structured architecture in a Clean way using an arch module that abstracts the implementation for a Clean Architecture. This module will be published separately as a Gradle dependency in the future but as a demonstration purpose it is added as another project module. It's name is
Archer
because internally uses Arrow as a functional framerwork tool.
This project may look overenginered for the small feature that is implementing but the purpose of the project is just demonstrating how it would work in a larger projects where scaling is important.
This is the resulting app (Take in consideration that the resolution is not the real one due to limitations of file size).
The app contains 2 screens.
- Loading Screen: Shows an animated cup of tea with several changing texts while fetching the data to display.
- Posts Screen: A List of posts
- The header will collapse with a animation taking the scroll of the list as anchor.
- The first item displayed will collapse to give space to the toolbar and will expand while scrolling.
- Each item in the list has a gradient background above the title and body that it's tinted taking the main color of the featured image of each post.
- Each item contains a Post Information including: Title, Body, Author and Number of Subscribers
Loading | Loaded |
---|---|
In this project we will be using Gradle with Kotlin Script (kts). All the dependencies are in the buildSrc
folder grouped by their function. So it's easy to use and less verbose.
This project is following a Clean architecture, along with MVVM for Presentation layer.
The project is divided with different modules:
- FrashlyPressed: Contains the app initialization with all the screens.
- Domain: Contains all the business logic. It contains the ViewModels, that exposes the behavior to the UI, The use cases and all the datasources that will make real estate calls to database and API's and will also provide the Dependency Injection modules that will provide them.
- Arch: It's an abstraction for clean architecture. Providing all the required classes that are usually involved in this architecture Like UseCase, Repository and DataSource and some other handy helper objects.
- BuildSrc: It's a module that will share basig gradle dependencies over all modules. Working with gradle kts files.
This concept means that all the data flow goes in a single direction. From the lower layer DataSource
to the upper one Fragments
. The communication through layers in an upper direction is made through events. Each layer will expose their dependencies (following the dependency inversion principle).
This module contains all the architecture related elements. It's purpose it's to provide all the boilerplate behavior usually implemented in Clean Architecture. In the following section we are going to describe all the components inside this module
Either is a Functional type inside Arrow's library. It follows monad comprehensions and is a wrapper for either a success value or a failure over an operation. It's totally operable with coroutines and can handle concurrency problems easily.
It's divided in 2 branches Left and Right, they are mutually exclusives. The regular conventions is to treat the left side of the object as a failure and the right one as a success. As the Either
short circuits on failure we can always work with it like if we are working with our expected values (Right side).
An example of parallel execution with either and coroutines in the project is inside DefaultGetPostsUseCase.kt
:
either {
val posts = !repository.getAll(query = query, operation = operation)
posts
.filter { it.featuredImage != null } //we just want posts with featured images
.parTraverse { post ->
val host: String? = try {
URI.create(post.authorUrl).host
} catch (e: IllegalArgumentException) {
null
}
if (host.isNullOrBlank()) {
post
} else {
post.copy(
numberOfSubscribers = !getsubsCountUseCase(
SubscribersQuery(host)
).handleError { 0L })
}
}
}
The first either
is a DSL function that creates a coroutine that lets us work imperatively without caring about monad comprehensions. In this case the operator !
(not
) is calling a bind()
function that unwraps the returning Either
of the repository. After that we are concurrently mapping each Post
a parTraverse
operator that it's essentially a map
function that runs in parallel for each element.
For more information on Comprehensions over Coroutines follow this Link.
For more information about parTraverse
follow this Link.
This object represents a source of data. In this project we have 2 common implementations: One wrapping API calls and another one wrapping Database calls for each model that we have. In this case we only provide datasorces for Posts
.
Follow the Single Responsibility Principle and Interface Segregation Principle a Datasource is fragmented in 3 different interfaces, where T
is a generic type:
GetDataSource<T>
-> providing get|getAll methodsPutDataSource<T>
-> providing put|putAll methodsDeleteDataSource<T>
-> providing delete|delete All methods
All three interfaces together compose a CRUD pattern. Segregating each interface allows us to implement just the concrete functionalities that we want.
As a example we can take the current apps behavior. We implemented a simple cache using database that never delete their tems so we haven't implemented the DeleteDataSource
.
In out PostsModule.kt
file we can see how we are creating a DataSource
without Delete feature:
DataSourceMapper<PostDBO, Post>(
getDataSource = databaseDatasource,
putDataSource = databaseDatasource,
deleteDataSource = VoidDeleteDataSource(), // -> We are not providing a DeleteDataSource here.
toOutMapper = PostDboToPostMapper,
toInMapper = PostToPostDboMapper
)
We are going to talk about DataSourceMapper later in this document.
There are built in DataSources
in this module ready to use:
DeviceStorageDataSourve.kt
ADataSource
that usesSharedPreferences
to store data.InMemoryDataSource.kt
ADataSource
that uses in-memory variables to store data. It can be commonly used to store data at execution time that we won't persist each time we destroy the app.
Following the Dependency Inversion Principle Queries are the main communication point of the DataSoures
. They are abstractions of data that will be provided from higher layers of the architecture.
There are some generic Queries
already provided like IdQuery
, KeyQuery
or VoidQuery
but commonly a developer should create their own one with the data he/she wants to read.
An example of Query
implemented in the app is PostsQuery.kt
that holds the parameters we need in order to retrieve a list of Posts
data class PostsQuery(val forceRefresh: Boolean, val number: Int = 10) : Query()
A Repository
is a data structure meant to hold one or several DataSources and Bridge the communication with higher level objects. A Repository exposes a Operation object that will be used to determine where do we want/need the information to come from.
There is a CacheRepository.kt
, already provided in the module, that provides cache logic implementation using Operations
. The developer only needs to provide the required DataSources
in order to apply it's behaviour.
Remember that everything works assuming that not every dependency can be satisfied and the developer can provide
VoidDataSources
or anyVoid-*
object as a parameter if he/she doesn't want to provide an implementation.
An example of how to create a CacheRepository.kt
:
CacheRepository(
getMain = networkDataSourceMappaer,
putMain = VoidPutDataSource(),
deleteMain = VoidDeleteDataSource(),
getCache = databaseDataSourceMapper,
putCache = databaseDataSourceMapper,
deleteCache = VoidDeleteDataSource()
)
Notice that there are 2 different types of sources;
main
andcache
. Where main would be, usually, a remote datasource (like a network one) and the second one will be a in-memory datasource or database. In any case they are abstractions and the developer is free to implement their prefered behaviours.
In this example we don't provide inserts in network (main
) nor removals in database (cache
)
Operations have been introduced in Repository section. They are ID ojects that will be used for the Repository
in order to select the datasource that will provide the data.
The project provides built in Operations
:
- DefaultOperation -> A default operation without specific behaviour
- MainOperation -> Data stream will only use the main data source
- MainSyncOperation -> Data stream will use the main data source and then sync result with the cache data source
- CacheOperation -> Data stream will only use the cache data source
- CacheSyncOperation -> Data stream will use the cache data source and sync with the main data source
The CacheRepository
uses actively this Operators
, as a example we will show how a get()
is performed:
when (operation) {
is DefaultOperation -> get(query, CacheSyncOperation)
is MainOperation -> getMain.get(query)
is CacheOperation -> getCache.get(query)
is MainSyncOperation -> getMain.get(query)
.flatMap { putCache.put(query, it) }
.handleErrorWith { failure ->
when (failure) {
is Failure.NoConnection, is Failure.ServerError -> {
get(query, CacheOperation)
.mapLeft { failure }
}
else -> Either.Left(failure)
}
}
is CacheSyncOperation -> {
return getCache.get(query).handleErrorWith {
when (it) {
is Failure.DataNotFound -> get(query, MainSyncOperation)
else -> Either.left(it)
}
}
}
}
Failure
are Exception abstractions. They are used to scope the exception in a closed environment. There are 8 different types of failure:
DataNotFound
-> Data can't be foundDataEmpty
-> Data that we are passing in a lower layer is empty (for instancenull
or an empty list of objects)NoConnection
-> We can't connectServerError
-> A server error happenedQueryNotSupported
-> the query that we are passing is not validInvalidObject
-> Data passed is not valid or has been invalidatedUnsupportedOperation
-> the operation that we are using in a repository is not supportedUnknown
-> an unhandled exception wrapper.
Mappers
are interfaces used to transform data between layers. They allow you to isolate implementation over objects like DataSource
and/or Repositories
without coupling them. They also implement a simple (T) -> R
function.
Thanks to mappers we provided several Adapter objects to easily transition between models. Take a look into:
Each DataSource
and Repository
contains transformation methods to help create a data stack easily. Sometimes we just need a Repository
that only contains a single DataSource
. To preserve the architecture integrity we have DataSource.toXRepository()
and DataSource.withMapping(Mapper)
. The first one Will create the homologue Repository
and the second one will create a DataSourceMapper
with the specified Mapper
A example of this transformation can be seen in PostsModule.kt
val networkDatasource: GetDataSource<PostEntity> = GetPostsNetworkDataSource(postsService)
val networkDataSourceMappaer : GetDataSource<Post> = networkDatasource.withMapping(PostEntityToPostMapper)
or also
val networkDatasource: GetDataSource<PostEntity> = GetPostsNetworkDataSource(postsService)
val networkDataSourceMappaer : GetDataSource<Post> = networkDatasource.withMapping + PostEntityToPostMapper
GetRepository
and GetDataSource
provides a plus operator with mappers to generate it's mapping homologue.
A UseCase
represents a business functional requirement. They usually contain a Repository or other UseCase
and apply any required logic (Like filtering, combination, modification, etc...)
There are 2 types of UseCase
: Parameterized and non parameterized, represented by ParametrizedUseCase.kt
and UseCase.kt
respectively. Both of them are abstract classes that follow the Command Pattern.
The difference between them is that the first one accepts a parameter in it's execution function defined as a Query.
A UseCase
is scoped in a coroutine context and it forces a CoroutineDispatcher
through the constructor.
This module contains all the business logic of the application. It also contains all the data retrieval logic but this logic is hidden for foreign modules.
Domain module only exposes UseCase
and ViewModels
.
Only the UseCase and ViewModels are exposed outside the module. The purpose of this implementation is to provide a black box of utilities that will be implemented in a UI. Taking in consideration that technologies like Kotlin Multiplarform are appearing we decided to extract presentation logic to this module and share it over other targets,
Each feature developed is separated in its own package inside com.m2f.domain.features
. Each package contain:
This package will contain all the API
's, DataSource and Entities
required for the feature.
All the elements inside this package are tagged with the
internal
keyword to prevent them from being exposed outside the module.
This package contains the Hilt modules that will provide the required UseCases
.
As we are working with internal components in the module we are using interfaces provided by Arch Module to hide the real implementations.
This package will provide all the mapping through Entities
and Business Models.
This the mappers are tagged with
internal
to prevent them to be exposed
This package exposes the Business Models for the feature.
This package exposes the queries.
This package provides all the UseCase. In this case we are creating sealed interfaces for each UseCase to hide the real implementation of the UseCase and prevent foreign modules to implement them.
This Package exposes all the ViewModels
related with this feature.
As we have seen in Unidirectional Data Flow there is a State object sent from the ViewModel
to the UI
.
A ViewModel provides this object as a wrapper of the data that will be sent to the UI with some other rendering information.
This is the implementation of the State
class:
sealed class ViewModelState<out T> {
data class Loading(val isLoading: Boolean) : ViewModelState<Nothing>()
object Empty : ViewModelState<Nothing>()
data class Success<T>(val data: T) : ViewModelState<T>()
data class Error(val failureType: FailureType) : ViewModelState<Nothing>()
}
It's a simple sealed class with 4 different states,
- one to enable/disable loading
- another to notify that we have empty data
- another one to send the actual data
- and a last one to notify about a error
These states are sent from the ViewModel
through LiveData
, an Observable object that's aware of the lifecycle of the UI in order to cancel their subscribers observation.
This Module contains all the Screens and UI components of the app. Its composed of two main packages:
- components: This contain a CollapsingToolbar and a modal loading dialog both of them can be seen in Result
- features: this package contain each feature that is present in Domain's feature package and contain all the UI related.
This module also provides the entry point for the Dependency Injection: take a look into MyApplicaton.kt
Palette is a library that allows identifying color schemes of a bitmap. In order to tint the title gradient of each post we called a Glide's callback to obtain the bitmap and used Palette to retrieve the main color wrapping all this workflow inside a suspendCancellableCoroutine
.
CollapsingToolbar is a custom view that internally combines a MotionLayout and a SeekableAnimatedVectorDrawable.
The vector animation has been developed through Shape Shifter Beta.
Thanks to the MotionLayout
we can achieve this kind of parallax-resizing animations:
This module contains the dependencies for the other modules. It's composed of 4 files:
- BaseDependencies.kt -> It provides all the dependencies of the project
- AppProperties.kt -> It provides the android properties that will be shared in all modules
- BuildVariants.kt -> It provides BuildVariant information
- ModuleDependencies.kt -> It provides intermodule dependencies.
All modules contain tests but we'll comment on some mocking strategies we have been following in order to isolate behaviors while testing network and database.
In order to isolate test from the real network layer we have implemented a two custom network Dispatchers MockNetworkDispatcher and MockNetworkKoDispatcher for failing responses.
This network dispatchers internally use a MockApiCallFactory that internally create MockApiCall a interface that works as a Strategy that generates a response. This response is read though a helper function that read stored json files containing the test responses.
The json files have been generated using Postman to obtain the real results from the API.
We also created a Decorator for Dispatchers
named ModifierDispatcher that uses ResponseModifier to add extra behavior to the network responses.
An example is TimeoutModifier that forces a timeout on the current response.
Example of usage can be found in GetNumSubscribersNetworkDatasourceTest.kt line 100:
mockWebServer.dispatcher = dispatcher + TimeoutModifier
It is overriding the plus operator to create a new Dispatcher
operator fun Dispatcher.plus(modifier: ResponseModifier): Dispatcher {
return ModifierDispatcher(this, modifier)
}
PostsDatabaseDataSource.kt has a particularity. To preserve data integrity over read/writes we added a Mutex
in it, locking the acces of the get and put for a thread when another one is performing one of these operations. To test that, in fact, this is working we implemented a special test following this kotlin feed that consist of creating several coroutines and check that everything has been performed as expected.
In order to isolate the test we create a JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
for each test that will be recreated each time.