A Reader monad implemented in Kotlin
Reader<In, Out>
is to provide higher abstraction of operation requires access to the external/shared environment in order to produce the desired output.
It helps you abstract away the computation into the very last moment (until runReader
function is called). One of the benefit for Reader monad is to make the DI (dependency injection) easy, safe and straightforward.
The latest version is TBD
<TBD>
Reader
helps us perform the same computation with different values.
The simplest use-case for Reader monad is to use as a DI (Dependency injection). Let's assume that we have a class that interacts with user account.
interface AccountService {
fun getAccount(accoundId: String, session: Session): Future<Account>
fun getBalance(account: Account, session: Session): Future<Amount>
fun getStatement(account: Account, session: Session): Future<Statement>
}
And the concrete implementation like the following
object Accounts : AccountService {
override fun getAccount(accoundId: String, session: Session): Future<Account> {
//real implementation
}
override fun getBalance(account: Account, session: Session): Future<Amount> {
//real implementation
}
override fun getStatement(account: Account, session: Session): Future<Statement> {
//real implementation
}
}
It looks a bit annoying as we need to provide session
for all of the methods. One can provide a DI at the constructor but it has 2 drawbacks.
Firstly, your class becomes stateful and it is start to be tangled with the construction of your dependency.
Secondly, once you have multiple things to pass into constructor, the constructor became bloated and un-scalable.
With Reader monad, you can improve above implementation to take advantage of Reader
like this.
typealias SessionReader<T> = Reader<Session, T>
interface AccountService {
fun getAccount(accoundId: String): SessionReader<Future<Account>>
fun getBalance(account: Account): SessionsReader<Future<Amount>>
fun getStatement(account: Account): SessionReader<Future<Statement>>
}
object ReaderAccounts : AccountService {
override fun getAccount(accoundId: String): SessionReader<Future<Account>> =
Reader { session: Session ->
session.doSomething() //do something before returning account, check authetication, status etc.
session.fetchAccount(accountId)
}
override fun getBalance(account: Account): SessionsReader<Future<Amount>> =
Reader { session: Session ->
session.doSomething()
session.fetchAmount(account)
}
override fun getStatement(account: Account): SessionReader<Future<Statement>> =
Reader { session: Session ->
session.doSomething()
session.fetchStatement(account)
}
}
As you can see now, we are using Reader
to abstract away our dependency usage. Reader
is meant to be composable so you can do something really cool like chaining a series of steps in one nice operation.
//somewhere in your application
fun getStatementReader(accountId: String) =
ReaderAccounts.getAccount(accountId).flatMap { ReaderAccounts.getStatement(it) }
fun getBalanceReader(accountId: String) =
ReaderAccounts.getBalance(accountId).flatMap { ReaderAccounts.getBalance(it) }
Nice thing about this is that we are not returning the result (yet), however we are returning the gist of steps in our operation as a descriptive Reader
. The expression up until this point is pure, as it has no execution happening yet.
It waits for the session
object to be filled then produce the desired result.
val statement = getStatementReader("1234").runReader(Session(UserConfig("xxx"))
//use statement
val balance = getBalanceReader("1234").runReader(Session(UserConfig("xxx"))
//use balance
One can use this technique to provide a different dependency at will. For example, you might want to provide session that always expired. (it can be useful in testing, as you wanna see that your program behaves correctly in different scenarios.)
//in Test context
val balanceReader = getBalanceReader("1234")
@Test
fun test_expiredSession() {
object AlwaysExpiredSession : Session {
// implementation detail that makes this session expired
}
val balance = balanceReader.runReader(AlwaysExpiredSession)
assertFalse(balance.valid) //balance is not valid because Session is expired
}
@Test
fun test_normalSession() {
val balance = balanceReader.runReader(ValidSession())
assertTrue(balance.valid)
}
This allows us to have the same (yet pure) operation that is reusable, and simple to understand and easy to test. Plus, there is no mock
, no clunky setup. You just provide different environments at the different time.
map
and flatMap
are common transformation function. map
transform Reader of one output to another type of output.
E.g. from the previous example, you want to get name of the account. You can use map
to transform Reader<Session, Account> to Reader<Session, String> where String
represents name of account.
val accountName = ReaderAccounts.getAccount("1234").map { it.name }.runReader(Session())
flatMap
is handling the transformation in a similar fashion but the allows to accept (U) -> Reader<T, Another>
instead.
pure
is a bridge into the Reader
, it basically accept one value with any type and then return that type as an output.
ask
is an identity Reader, ask
will return the environment that it passed on so it can be useful, if you want to pass along the input in the chain.
ReaderK is released under the MIT license.