Introduction
As more and more people discover F#, and functional programming in general, a common question asked is how to do dependency injection. One solution to this problem is to use the Reader monad. While there are many benefits to the Reader monad one of the biggest drawbacks is that it can be difficult to use in tandem with other common types, such as Async, or Result. In this article we will have a look at one approach for solving this drawback.
Reader Monad
Here we start with a basic example, that is a simple version of a workflow we use at Equip, where we are trying to create a new organisation and save it to a database. In our example an organisation consists of a unique identifier and a collection of associated currencies. The input parameters are a collection of strings that identify the currencies to associate (for example: “AUD”, “USD”, “GBP”).
type OrganisationId = OrganisationId of Guid
type CurrencyCode = | CurrencyCode of string
type Currency = {
CurrencyCode: CurrencyCode
CurrencyName: string
}
type Organisation = {
OrganisationId: OrganisationId
OrganisationCurrencies: Currency list
}
type IOrganisationIdGenerator =
abstract member GenerateId: Unit -> OrganisationId
type ICurrencyService =
abstract member GetCurrencies: OrganisationId -> string list -> Currency list
type IDataAccess =
abstract member WriteOrganisation: Organisation -> Unit
let getOrganisationId (logger:ILogger) (generator:IOrganisationIdGenerator) =
let orgId = generator.GenerateId()
logger.LogInformation($"Organisation ID generated: {string orgId}")
orgId
let getOrganisationCurrencies orgId dtoCurrencies (logger:ILogger) (currencyService:ICurrencyService) =
let currencies = currencyService.GetCurrencies orgId dtoCurrencies
let currencyString =
currencies
|> List.map (fun c -> c.CurrencyName)
|> List.reduce (fun a b -> $"{a}, {b}")
logger.LogInformation($"Currencies: {currencyString}")
currencies
let saveOrganisation organisation (logger:ILogger) (dataAccess:IDataAccess) =
logger.LogInformation("Writing organisation to database")
dataAccess.WriteOrganisation organisation
let createOrganisation dtoCurrencies (logger:ILogger) (generator:IOrganisationIdGenerator) (currencyService:ICurrencyService) (dataAccess:IDataAccess) =
let orgId = getOrganisationId logger generator
let currencies = getOrganisationCurrencies orgId dtoCurrencies logger currencyService
let organisation = { OrganisationId = orgId; OrganisationCurrencies = currencies }
saveOrganisation organisation logger dataAccess
The createOrganisation function performs the following steps:
- Generate a new unique identifier.
- Using the newly created organisation Id and the currency identifiers, call the currency service (which may be an http call to a separate API) to return the collection of currencies.
- Create the organisation record and pass that to the data access layer which would write it to a database.
- Log information at each step.
Rather than passing each dependency in as separate parameters one thing we could improve here is to combine the dependencies into a single object and pass that to the functions instead.
type IContext =
abstract member Logger : ILogger
abstract member OrganisationIdGenerator: IOrganisationIdGenerator
abstract member CurrencyService : ICurrencyService
abstract member DataAccess : IDataAccess
Grouping the dependencies together creates an Inversion of Control (IoC) where the parent function (createOrganisation) does not need to know the specific dependencies the child functions require, only that it needs to pass the IContext (from an aesthetic and readability perspective, grouping your dependencies together make your function definitions much easier to read).
All functions can now be updated to accept IContext as a single parameter.
let getOrganisationId (context:IContext) =
let orgId = context.OrganisationIdGenerator.GenerateId()
context.Logger.LogInformation($"Organisation ID generated: {string orgId}")
orgId
let getOrganisationCurrencies orgId dtoCurrencies (context:IContext) =
let currencies = context.CurrencyService.GetCurrencies orgId dtoCurrencies
let currencyString =
currencies
|> List.map (fun c -> c.CurrencyName)
|> List.reduce (fun a b -> $"{a}, {b}")
context.Logger.LogInformation($"Currencies: {currencyString}")
currencies
let saveOrganisation organisation (context:IContext) =
context.Logger.LogInformation("Writing organisation to database")
context.DataAccess.WriteOrganisation organisation
let createOrganisation dtoCurrencies (context:IContext) =
let orgId = getOrganisationId context
let currencies = getOrganisationCurrencies orgId dtoCurrencies context
let organisation = { OrganisationId = orgId; OrganisationCurrencies = currencies }
saveOrganisation organisation context
These four functions now share something in common. At an abstract level we describe their function definitions as inputs -> IContext -> output, or more plainly, each function takes some inputs (none, one, or many) and an IContext, and will give you an output. Because functions are first class objects in F# we can reinterpret the above statement as each function takes some inputs and will give you back a function that takes an IContext and gives you an output (this seems like semantics, but it is important).
let saveOrganisation organisation =
fun (context:IContext) ->
context.Logger.LogInformation("Writing organisation to database")
context.DataAccess.WriteOrganisation organisation
This reinterpretation is a very common pattern in functional programming and is called the Reader monad (or sometimes the environment monad). We can define the Reader monad as a generic type in F#.
type Reader<'environment,'a> = Reader of ('environment -> 'a)
What this definition is telling us is that a Reader is a function that takes an environment and returns a generic value. In our example above if we implemented the Reader type the environment would be IContext.
let saveOrganisation organisation =
Reader (fun (context:IContext) ->
context.Logger.LogInformation("Writing organisation to database")
context.DataAccess.WriteOrganisation organisation
)
If you look at the definition of saveOrganisation now you can see that it is Organisation -> Reader<IContext,Unit>, or simply it takes an Organisation and will give you a Reader<IContext,Unit> as an output (which in turn is a function that takes an IContext and returns a Unit).
Ok, so what is the point of all this?
Well, because a Reader is a monad it can be easily composed with other Reader types. In order to do this we need to create return, bind, and map functions.
let run environment (Reader action) =
let resultOfAction = action environment
resultOfAction
let map f action =
let newAction environment =
let x = run environment action
f x
Reader newAction
let retn x =
let newAction environment =
x
Reader newAction
let bind f xAction =
let newAction environment =
let x = run environment xAction
run environment (f x)
Reader newAction
And in F#, because we have a bind function, it is easy to then create a computational expression.
type ReaderBuilder() =
member __.Return(x) = retn x
member __.ReturnFrom(x) = x
member __.Bind(x,f) = bind f x
member __.Zero() = retn ()
let reader = ReaderBuilder()
Now we have all the tools to refactor our original example using the Reader monad and compose them using our computation expression.
let getOrganisationId =
Reader (fun (context:IContext) ->
let orgId = context.OrganisationIdGenerator.GenerateId()
context.Logger.LogInformation($"Organisation ID generated: {string orgId}")
orgId
)
let getOrganisationCurrencies orgId dtoCurrencies =
Reader (fun (context:IContext) ->
let currencies = context.CurrencyService.GetCurrencies orgId dtoCurrencies
let currencyString =
currencies
|> List.map (fun c -> c.CurrencyName)
|> List.reduce (fun a b -> $"{a}, {b}")
context.Logger.LogInformation($"Currencies: {currencyString}")
currencies
)
let saveOrganisation organisation =
Reader (fun (context:IContext) ->
context.Logger.LogInformation("Writing organisation to database")
context.DataAccess.WriteOrganisation organisation
)
let createOrganisation dtoCurrencies =
reader {
let! orgId = getOrganisationId
let! currencies = getOrganisationCurrencies orgId dtoCurrencies
let organisation = { OrganisationId = orgId; OrganisationCurrencies = currencies }
do! saveOrganisation organisation
}
What this ultimately achieves is that in the createOrganisation function the handling of the IContext dependency, and passing it to all the functions in the workflow that require it, is now managed by the computation expression.
Additionally, from a macro perspective the readability of the code is also improved. Even if you didn’t have any knowledge about the Reader monad you could look at the createOrganisation function and deduce the key steps in the workflow.
Real world applications
The above example demonstrates the fundamental aspects of how Reader monads can work in F#, however, it is a little contrived. It doesn’t consider any potential errors, or any asynchronous workflows, both of which are very common in modern applications. Let’s stretch our example and introduce some error handling and see how this affects the framework.
We’re going to introduce the Result type to the organisation Id generator by changing the definition of the GenerateId member.
type IOrganisationIdGenerator =
abstract member GenerateId: Unit -> Result<OrganisationId,string>
We now need to update the createOrganisation function to handle the fact that getOrganisationId returns a Reader<IContext,Result<OrganisationId,string>>.
let createOrganisation dtoCurrencies =
reader {
let! orgIdRes = getOrganisationId
return!
match orgIdRes with
| Ok orgId ->
reader {
let! currencies = getOrganisationCurrencies orgId dtoCurrencies
let organisation = {
OrganisationId = orgId;
OrganisationCurrencies = currencies
}
do! saveOrganisation organisation
return Ok ()
}
| Error errs -> Reader.retn (Error errs)
}
This has massively increased the complexity of our workflow. It’s now very difficult to read and at first glance trying to figure out what createOrganisation does is not easy. We also haven’t updated any of the other functions (calling the database and the currency service would certainly benefit from robust error handling) and have still not considered asynchronous workflows.
So what we need is a way of getting the benefits of Reader, Async, and Result, while keeping our code composable, simple to write, and easy to read.
Reader Async Result
In order to do this we are going to build on an idea I was first introduced to in the series Map and Bind and Apply, Oh My! by Scott Wlaschin. In the series one of the ideas he discusses is how you can treat Async and Result as a single type. We’re going to take that one step further and treat Reader, Async, and Result as a single type, which logically we will call ReaderAsyncResult.
Like the Reader monad we can define the return, bind, and map functions for a ReaderAsyncResult.
let map f =
Reader.map (fun xAsync ->
async {
let! x = xAsync
return Result.map f x
}
)
let retn x =
Reader.retn (
async {
return Ok x
}
)
let bind (f: 'a -> Reader<'b,Async<Result<'c,'d>>>) xActionResult : Reader<'b,Async<Result<'c,'d>>> =
Reader( fun environment ->
let xAsyncResult = Reader.run environment xActionResult
async {
let! xResult = xAsyncResult
let yAction =
match xResult with
| Ok x -> f x
| Error err ->
Reader.retn (
async {
return (Error err)
}
)
return! Reader.run environment yAction
}
)
Because we have the bind function we can again create a computational expression.
type ReaderAsyncResultBuilder() =
member this.Return(x) = retn x
member this.ReturnFrom(x) = x
member this.Bind(x, f) = bind f x
member this.Zero() = retn ()
let readerAsyncResult = ReaderAsyncResultBuilder()
So what does this all mean.
Well, a Reader monad is an abstraction for a function that takes an environment and returns an output. So, a ReaderAsyncResult is a Reader that takes an environment (in our example the IContext dependency) and returns an asynchronous workflow that ultimately returns a Result type.
The bind function that we have written will pass the environment (IContext) to the Reader, wait for the asynchronous workflow to finish executing, and then evaluate the result of that workflow. If the result is successful, then that value will be passed into the next function (which results in another ReaderAsyncResult). If the result is an error, then the next function will not be executed (exactly how a simple Result.bind works).
Going back to our original example we can update all our interfaces to return AsyncResult types, which is a more realistic scenario.
type IOrganisationIdGenerator =
abstract member GenerateId: Unit -> Async<Result<OrganisationId,string>>
type ICurrencyService =
abstract member GetCurrencies: OrganisationId -> string list -> Async<Result<Currency list,string>>
type IDataAccess =
abstract member WriteOrganisation: Organisation -> Async<Result<Unit,string>>
Now that the dependencies are updated, we can update the rest of the code to use the ReaderAsyncResult type.
let generateId =
Reader (fun (context:IContext) ->
context.OrganisationIdGenerator.GenerateId()
)
let getCurrencies orgId dtoCurrencies =
Reader (fun (context:IContext) ->
context.CurrencyService.GetCurrencies orgId dtoCurrencies
)
let writeOrganisation organisation =
Reader (fun (context:IContext) ->
context.DataAccess.WriteOrganisation organisation
)
let logMessage (message:string) =
Reader (fun (context:IContext) ->
async {
context.Logger.LogInformation(message)
return Ok ()
}
)
let getOrganisationId =
readerAsyncResult {
let! orgId = generateId
do! logMessage $"Organisation ID generated: {string orgId}"
return orgId
}
let getOrganisationCurrencies orgId dtoCurrencies =
readerAsyncResult {
let! currencies = getCurrencies orgId dtoCurrencies
let currencyString =
currencies
|> List.map (fun c -> c.CurrencyName)
|> List.reduce (fun a b -> $"{a}, {b}")
do! logMessage $"Currencies: {currencyString}"
return currencies
}
let saveOrganisation organisation =
readerAsyncResult {
do! logMessage "Writing organisation to database"
do! writeOrganisation organisation
}
let createOrganisation dtoCurrencies =
readerAsyncResult {
let! orgId = getOrganisationId
let! currencies = getOrganisationCurrencies orgId dtoCurrencies
let organisation = { OrganisationId = orgId; OrganisationCurrencies = currencies }
do! saveOrganisation organisation
}
Stepping through the createOrganisation function, what this expression will accomplish is that when run it will pass an IContext to the getOrganisationId function. This will run asynchronously and return either an OrganisationId or an error. If an OrganisationId is returned this will be passed into getOrganisationCurrencies, which in turn will receive an IContext and asynchronously retrieve the currencies (or an error). If at any point an error is received, then additional steps will not execute and the error will be surfaced as the output of createOrganisation.
If we compare createOrganisation to the earlier example, where we only used the Reader monad, we can see that the same advantages apply here (readability, reusability etc.). However, by introducing Async and Result we have created more advanced workflows that can better model realistic domain processes.
Conclusion
Treating Reader, Async, and Result as a single type does solve the original issue where using a Reader to achieve dependency injection can be difficult to use in tandem with Async and Result.
Like any software tool, however, there are cons you need to weigh up. Even though our final example is much more realistic, it is still modelled in such a way as to easily demonstrate the problem a ReaderAsyncResult solves. In real applications you may end up needing to do a bit more manipulation to match the various types. For example, you may have some functions that just return a Result (not Async) and want to add them to your ReaderAsyncResult expression. Though this can be achieved by using an Async return function and then a Reader return function (or you could take the same principals discussed earlier and create a ReaderAsync type) it could be, depending on your code base, an undesirable overhead.
It can also make your code base more inaccessible to users who are unfamiliar with function programming. It’s almost a joke at this point about how scary the word monad is to new F# developers. However, introducing the Reader monad in parallel with how you can combine Reader, Async, and Result into a new monad (and that in F# Async is a monad) might create a significant barrier to entry.
Overall though if you are comfortable introducing these concepts into your application, and feel that the benefits (readability, reusability, creating pure functions etc.) outweigh any negatives then it can be a really powerful tool to use.
At Equip we use ReaderAsyncResult in some of our backend API’s so in our next article we will look at how you integrate this framework in .NET.