Dependency injection in F#. The missing manual.

Vladimir Shchur
8 min readMay 23, 2024

--

F# dependency injection, the missing manual

Dependency injection management has historically been a trump card in object oriented languages like C# or Java. A galore of DI containers and frameworks has been developed, some aged well others didn’t, but they all look and feel more or less the same. You write code using constructor injection or property injection or service locator, then you register dependencies in a large file with all dependencies and then you hope that everything will work in the runtime. While there is an obvious flaw that it’s not compile-time safe (it’s easy to forget to register a dependency, do it in a wrong place or with a wrong scope), people keep happily using it and don’t know any better while facing exceptions in runtime.

How is DI problem being solved in F#? Well, there are many choices for “functional style” DI, but no one really is generic and convenient enough to cover all cases. Also, popular F# web frameworks such as Giraffe, Saturn and Falco don’t really encourage using any of those methods and just suggest using built-in ASP.NET “OOP style” DI. Therefore people who are relatively new to F# are often get lost and don’t know how to incorporate DI management in their applications.

I was in the same situation several years ago when I had a chance to start a relatively big project in F#. We were already aware of the article about dealing with complex DI in F# and others, but had actually to rewrite app architecture four times (which went really smooth thanks to F#) before the stable DI “framework” emerged. What I really like that it can be derived from lean principles, so it feels very natural and I’ve successfully used it in all kinds of applications afterwards. So let’s do it!

Problem

Let’s imagine the following task — we need to write a function that sends notification to user about theirs favorite football team playing this evening. To do so we need to load user settings, look up whether Email or SMS notification preference is set and then send Email or SMS accordingly.

let notifyUser userId message =
task {
let! userSettings = DatabaseService.getUserSettings userId
match userSettings.NotificationType with
| Email address ->
return! EmailService.sendEmail address message
| Sms phone ->
return! SmsService.sendSms phone message
}

Nice and clean! However now we need to test it, and this is when we start thinking about DI.

Analysis

Our beautiful function depends on getUserSettings, sendEmail and sendSms, which are infrastructure functions and should be mocked, how do we pass them? Passing those as separate parameters is a no go, we’ll have too many parameters in the function, while empirical maximum is only 3 parameters. Making function a method and wrap with a class with constructor is too OOP-ish with it’s cons. Passing dependencies implicitly as a Reader monad quickly becomes problematic when you have several different monads in your application (we already have task builder). And even if you think you can get away with custom interpreter (as we did), things get really complicated when you need to run your asynchronous functions in parallel or any other pattern rather than simple sequential invocation.

So the most generic option would be to pass all the dependencies together as one parameter env:

let notifyUser env userId message =
task {
let! userSettings = env.GetUserSettings userId
match userSettings.NotificationType with
| Email address ->
return! env.SendEmail address message
| Sms phone ->
return! env.SendSms phone message
}

That’s a good solution, but the code doesn’t compile. We are still to decide how do we group those functions together. There are two possible options: as a record with functions or as an interface with methods. While records are the best choice for carrying data, they are not as good at carrying functions as fields. From developer experience it’s harder to navigate, find actual implementations and reuse each function definitions. There is also additional problem for both options:

type INotifyUserEnv = 
abstract member GetUserSettings: string -> Task<UserSettings>
abstract member SendEmail: string * string -> Task<unit>
abstract member SendSms: string * string -> Task<unit>

let notifyUser (env: INotifyUserEnv) userId message =
task {
let! userSettings = env.GetUserSettings(userId)
match userSettings.NotificationType with
| Email address ->
return! env.SendEmail(address, message)
| Sms phone ->
return! env.SendSms(phone, message)
}

type INotifyAllUsersEnv =
abstract member GetAllUserIdsToNotify: unit -> Task<string[]>
abstract member GetUserSettings: string -> Task<UserSettings>
abstract member SendEmail: string * string -> Task<unit>
abstract member SendSms: string * string -> Task<unit>

let notifyAllUsers (env: INotifyAllUsersEnv) message =
task {
let! userIds = env.GetAllUserIdsToNotify()
for userId in userIds do
do! notifyUser env userId message
}

Now as we added extra function notifyAllUsers that calls notifyUser we created a new problem — env parameter types are incompatible and have to be mapped, code above won’t compile. Another issue is that we need either to duplicate function definitions or build interfaces hierarchies or create giant common interfaces. First two are fragile to changes and lead to lots of boilerplate, while latter makes function depend on what they really should not depend. For example if we make our notifyUser function depend on INotifyAllUsersEnv, we’ll have to mock GetAllUserIdsToNotify in our tests which is undesirable. So, what should we do? Remarkably, F# compiler gives us the answer itself! Let’s see it in an easier example.

type IEnvA = 
abstract member DoWorkA: unit -> unit
type IEnvB =
abstract member DoWorkB: unit -> unit

let doWorkA (env: IEnvA) =
env.DoWorkA()
let doWorkB (env: IEnvB) =
env.DoWorkB()

let doWork env = // val env: 'a (requires 'a :> IEnvA and 'a :> IEnvB)
doWorkA env
doWorkB env

Surprisingly the code above compiles! F# compiler is smart enough to derive the type of env to be a generic type that is restricted to both interfaces. We don’t have to specify any type declarations in our main doWork function! Let’s apply this goodness to our notifyUser function.

Solution

type IGetUserSettings = 
abstract member GetUserSettings: string -> Task<UserSettings>
type ISendEmail =
abstract member SendEmail: string * string -> Task<unit>
type ISendSms =
abstract member SendSms: string * string -> Task<unit>
type IGetAllUserIdsToNotify =
abstract member GetAllUserIdsToNotify: unit -> Task<string[]>

let getUserSettings (env: IGetUserSettings) userId =
env.GetUserSettings(userId)
let sendEmail (env: ISendEmail) address msg =
env.SendEmail(address, msg)
let sendSms (env: ISendSms) phone msg =
env.SendSms(phone, msg)
let getAllUserIdsToNotify (env: IGetAllUserIdsToNotify) =
env.GetAllUserIdsToNotify()

let notifyUser env userId message =
task {
let! userSettings = getUserSettings env userId
match userSettings.NotificationType with
| Email address ->
return! sendEmail env address message
| Sms phone ->
return! sendSms env phone message
}

let notifyAllUsers env message =
task {
let! userIds = getAllUserIdsToNotify env
for userId in userIds do
do! notifyUser env userId message
}

Now our code looks almost exactly like initial version with the only small addition of env parameter here and there! But now we have all checkboxes checked

  • All dependencies are statically checked (and there is no DI container startup penalty)
  • No unnecessary mappings are needed between functions calls
  • Business logic looks nice and clean, even type annotations are not required
  • Each function has only it’s required dependencies, nothing extra

In example above I added helper functions getUserSettings, sendEmail and sendSms so types could be derived automatically, but it’s not required. Alternatively you could use F# flexible types and specify all required types explicitly.

let notifyUser (env: #IGetUserSettings & #ISendEmail & #ISendSms) 
userId message =
task {
let! userSettings = env.GetUserSettings(userId)
match userSettings.NotificationType with
| Email address ->
return! env.SendEmail(address, message)
| Sms phone ->
return! env.SendSms(phone, message)
}

All business logic functions are pure and can be easily tested just by varying their parameters. For example we can write the following tests (I use FsUnit framework):

type OperationEnv(notificationType) =
let mutable emailCount = 0
let mutable smsCount = 0

member this.EmailCount = emailCount
member this.SmsCount = smsCount

interface IGetUserSettings with
member this.GetUserSettings _ =
{ NotificationType = notificationType } |> Task.FromResult
interface ISendEmail with
member this.SendEmail(_,_) = task { emailCount <- emailCount + 1 }
interface ISendSms with
member this.SendSms(_,_) = task { smsCount <- smsCount + 1 }


[<Fact>]
let ``Sms get sent if user has sms in settings`` () =
task {
let operationEnv = OperationEnv(Sms "+123456789")
do! notifyUser operationEnv "user1" "Real Madrid is playing today!"
operationEnv.EmailCount |> shouldEqual 0
operationEnv.SmsCount |> shouldEqual 1
}

[<Fact>]
let ``Email get sent if user has email in settings`` () =
task {
let operationEnv = OperationEnv(Email "user@mail.com")
do! notifyUser operationEnv "user2" "Barcelona is playing today!"
operationEnv.EmailCount |> shouldEqual 1
operationEnv.SmsCount |> shouldEqual 0
}

All perfect, are we done yet? Well, attentive reader will notice that we are not passing real dependencies like database, SMS and Email clients. For that we need another type of environment — InfraEnv as opposed to OperationEnv that we have used till now. The separation is quite clear, OperationEnv handles operations injection, while InfraEnv provides infrastructure objects needed to implement those operations. I usually call it just Env instead of InfraEnv, but let’s keep InfraEnv in example for clarity.

type ISmsEnv =
abstract SmsClient: SmsClient
type IEmailEnv =
abstract EmailClient: EmailClient
type IDbEnv =
abstract DbClient: DbClient

type InfraEnv = {
SmsClient: SmsClient
EmailClient: EmailClient
DbClient: DbClient
} with
interface ISmsClient with
member this.SmsClient = this.SmsClient
interface IEmailClient with
member this.EmailClient = this.EmailClient
interface IDbClient with
member this.DbClient = this.DbClient

This type of environment can be initialized once in the root of your project and reused for all different scenarios. To do so you’ll need to convert InfraEnv to OperationEnv at the beginning of scenario handling:

type OperationEnv(env: InfraEnv) =
interface IGetUserSettings with
member this.GetUserSettings(userId) =
DatabaseService.getUserSettings env userId
interface ISendSms with
member this.SendSms(address, message) =
SmsService.sendSms env address message
interface ISendEmail with
member this.SendEmail(phone, message) =
EmailService.sendEmail env number message

module ScenarioHandler =
let notifyUser infraEnv userId =
let operationEnv = OperationEnv(infraEnv)
BusinessHandler.notifyUser operationEnv userId

Infrastructure services depend on infrastructure interfaces rather that business operation interfaces, so we are passing InfraEnv to them rather than OperationEnv. Specific infra interfaces are used in the same way - as a generic interface restrictions on env:

module DatabaseService =
let getUserSettings (env: #IDbEnv) userId =
task {
use connection = env.DbClient.CreateConnection()
return! env.DbClient.Query<UserSettings>(connection,
"SELECT * FROM UserSettings WHERE UserId = @UserId",
{| UserId = userId |})
}

Conclusion

We’ve covered all the steps needed to implement the DI “framework” in F#:

  1. Create InfraEnv (or just Env) in application root with all I/O infrastructure dependencies
  2. Create interface with one method for each I/O operation
  3. (Optional) create helper functions for call convenience
  4. For each business scenario create OperationEnv that implements all the required operations (F# compiler will tell you what to do) using InfraEnv and infrastructure services

Full working example with this approach can be found in the Oxpecker repository.

Thanks for reaching the end of the article and I wish the DI problem will never let you down!

--

--

Vladimir Shchur
Vladimir Shchur

Written by Vladimir Shchur

F# developer, contributing for greater good. You can sponsor my work on Github: https://github.com/sponsors/Lanayx

Responses (2)