Skip to content

Instantly share code, notes, and snippets.

@swlaschin
Last active February 25, 2023 06:05
Show Gist options
  • Save swlaschin/ef1d180bfde18a9b876eb8f54913c49e to your computer and use it in GitHub Desktop.
Save swlaschin/ef1d180bfde18a9b876eb8f54913c49e to your computer and use it in GitHub Desktop.
Code examples from fsharpforfunandprofit.com/posts/dependencies-5/
(* ===================================
Code from my series of posts "Six approaches to dependency injection"
=================================== *)
open System
(*
## The requirements
Say that we have some kind of web app with users, and each user has a "profile" with their name, email, preferences, etc.
A use-case for updating their profile might be something like this:
1. Receive a new profile (parsed from a JSON request, say)
2. Read the user's current profile from the database
3. If the profile has changed, update the user's profile in the database
4. If the email has changed, send a verification email message to the user's new email
We will also add a little bit of logging into the mix.
*)
(* ======================================================================
Common types used thoughout the examples
====================================================================== *)
module Domain =
type UserId = UserId of int
type UserName = string
type EmailAddress = EmailAddress of string
type Profile = {
UserId : UserId
Name : UserName
EmailAddress : EmailAddress
}
type EmailMessage = {
To : EmailAddress
Body : string
}
module Infrastructure =
open Domain
type ILogger =
abstract Info : string -> unit
abstract Error : string -> unit
type InfrastructureError =
| DbError of string
| SmtpError of string
type DbConnection = DbConnection of unit // dummy definition
type IDbService =
abstract NewDbConnection :
unit -> DbConnection
abstract QueryProfile :
DbConnection -> UserId -> Async<Result<Profile,InfrastructureError>>
abstract UpdateProfile :
DbConnection -> Profile -> Async<Result<unit,InfrastructureError>>
type SmtpCredentials = SmtpCredentials of unit // dummy definition
type IEmailService =
abstract SendChangeNotification :
SmtpCredentials -> EmailMessage -> Async<Result<unit,InfrastructureError>>
let globalLogger = {new ILogger with
member __.Info str = printfn "INFO %s" str
member __.Error str = printfn "ERROR %s" str
}
let defaultDbService = {new IDbService with
member __.NewDbConnection() = DbConnection()
member __.QueryProfile dbConnection (UserId userId) =
printfn "DB.QueryProfile: %A" userId
async {
let profile = {
UserId = UserId userId
Name = ""
EmailAddress = EmailAddress (sprintf "user%[email protected]" userId)
}
return Ok profile
}
member __.UpdateProfile dbConnection profileDto =
printfn "DB.UpdateProfile: %A" profileDto
async {
return Ok ()
}
}
let defaultSmtpCredentials = SmtpCredentials() // dummy
let defaultEmailService = {new IEmailService with
member __.SendChangeNotification smtpCreditials emailMessage =
printfn "Email.SendEmailChangedNotification: %A" emailMessage
async {
return Ok ()
}
}
(* ======================================================================
Result library
====================================================================== *)
type AsyncResult<'Success,'Failure> =
Async<Result<'Success,'Failure>>
module AsyncResult =
/// Lift a function to AsyncResult
let map f (xAS:AsyncResult<_,_>) : AsyncResult<_,_> =
async {
let! x = xAS
return (Result.map f) x
}
/// Lift a value to AsyncResult
let retn x : AsyncResult<_,_> =
x |> Result.Ok |> async.Return
let bind (f: 'a -> AsyncResult<'b,'c>) (xAsyncResult : AsyncResult<_, _>) :AsyncResult<_,_> = async {
let! xResult = xAsyncResult
match xResult with
| Ok x -> return! f x
| Error err -> return (Error err)
}
[<AutoOpen>]
module AsyncResultComputationExpression =
type AsyncResultBuilder() =
member __.Return(x) = AsyncResult.retn x
member __.Bind(x, f) = AsyncResult.bind f x
member __.ReturnFrom(x) = x
member this.Zero() = this.Return ()
member this.Combine (a:AsyncResult<_,_>, b:unit->AsyncResult<_,_>) = AsyncResult.bind b a
member this.Combine (a:AsyncResult<_,_>, b:AsyncResult<_,_>) = AsyncResult.bind (fun () -> b) a
member __.Delay(f) = async.Delay(f)
let asyncResult = AsyncResultBuilder()
(* ======================================================================
Approach 1. Dependency Retention
====================================================================== *)
module test =
let step1() = async {return 1}
let step2 x = async {return ()}
let y = async {
let! x = step1()
if x > 0 then
do! step2 x
}
module DependencyRetention =
open Domain
open Infrastructure
// val updateCustomerProfile : newProfile:Domain.Profile -> AsyncResult<unit,Infrastructure.InfrastructureError>
let updateCustomerProfile (newProfile:Profile) :AsyncResult<unit,InfrastructureError> =
let dbConnection = defaultDbService.NewDbConnection()
let smtpCredentials = defaultSmtpCredentials
asyncResult {
let! currentProfile = defaultDbService.QueryProfile dbConnection newProfile.UserId
if currentProfile <> newProfile then
globalLogger.Info("Updating Profile")
do! defaultDbService.UpdateProfile dbConnection newProfile
if currentProfile.EmailAddress <> newProfile.EmailAddress then
let emailMessage = {
To = newProfile.EmailAddress
Body = "Please verify your email"
}
globalLogger.Info("Sending email")
do! defaultEmailService.SendChangeNotification smtpCredentials emailMessage
}
(* ======================================================================
Approach 2. Dependency Rejection
====================================================================== *)
module DependencyRejection =
open Domain
// -----------------------------------------------
// Pure core
// -----------------------------------------------
module Pure =
let globalLogger = Infrastructure.globalLogger
type Decision =
| NoAction
| UpdateProfileOnly of Profile
| UpdateProfileAndNotify of Profile * EmailMessage
// pure code, which is easy to test
// (assuming globalLogger is allowed)
let updateCustomerProfile (newProfile:Profile) (currentProfile:Profile) =
if currentProfile <> newProfile then
globalLogger.Info("Updating Profile")
if currentProfile.EmailAddress <> newProfile.EmailAddress then
let emailMessage = {
To = newProfile.EmailAddress
Body = "Please verify your email"
}
globalLogger.Info("Sending email")
UpdateProfileAndNotify (newProfile, emailMessage)
else
UpdateProfileOnly newProfile
else
NoAction
// -----------------------------------------------
// Impure shell
// -----------------------------------------------
module Shell =
open Infrastructure
open Pure
// infrastructure services are hard-coded inline
let updateCustomerProfile (newProfile:Profile) :AsyncResult<unit,InfrastructureError> =
let dbConnection = defaultDbService.NewDbConnection()
let smtpCredentials = defaultSmtpCredentials
asyncResult {
// ----------- impure ----------------
let! currentProfile = defaultDbService.QueryProfile dbConnection newProfile.UserId
// ----------- pure ----------------
let decision = Pure.updateCustomerProfile newProfile currentProfile
// ----------- impure ----------------
match decision with
| NoAction ->
()
| UpdateProfileOnly profile ->
do! defaultDbService.UpdateProfile dbConnection profile
| UpdateProfileAndNotify (profile,emailMessage) ->
do! defaultDbService.UpdateProfile dbConnection profile
do! defaultEmailService.SendChangeNotification smtpCredentials emailMessage
}
(* ======================================================================
Approach 3. Dependency Parameterization
====================================================================== *)
module DependencyParameterization =
open Domain
// -----------------------------------------------
// Pure core
// -----------------------------------------------
module Pure =
type ILogger = Infrastructure.ILogger
type Decision =
| NoAction
| UpdateProfileOnly of Profile
| UpdateProfileAndNotify of Profile * EmailMessage
let updateCustomerProfile (logger:ILogger) (newProfile:Profile) (currentProfile:Profile) =
if currentProfile <> newProfile then
logger.Info("Updating Profile")
if currentProfile.EmailAddress <> newProfile.EmailAddress then
let emailMessage = {
To = newProfile.EmailAddress
Body = "Please verify your email"
}
logger.Info("Sending email")
UpdateProfileAndNotify (newProfile, emailMessage)
else
UpdateProfileOnly newProfile
else
NoAction
// -----------------------------------------------
// Impure shell
// -----------------------------------------------
module Shell =
open Infrastructure
open Pure
type IServices = {
Logger : ILogger
DbService : IDbService
EmailService : IEmailService
}
// Uses infrastructure but all interfaces are passed in as parameters
// This is easy to mock, or to change infrastructure implementation
let updateCustomerProfile (services:IServices) (newProfile:Profile) :AsyncResult<unit,InfrastructureError> =
let dbConnection = services.DbService.NewDbConnection()
let smtpCredentials = defaultSmtpCredentials
let logger = services.Logger
asyncResult {
// ----------- Impure ----------------
let! currentProfile = services.DbService.QueryProfile dbConnection newProfile.UserId
// ----------- pure ----------------
let decision = Pure.updateCustomerProfile logger newProfile currentProfile
// ----------- Impure ----------------
match decision with
| NoAction ->
()
| UpdateProfileOnly profile ->
do! services.DbService.UpdateProfile dbConnection profile
| UpdateProfileAndNotify (profile,emailMessage) ->
do! services.DbService.UpdateProfile dbConnection profile
do! services.EmailService.SendChangeNotification smtpCredentials emailMessage
}
/// Top-level "composition root"
let updateCustomerProfileApi (newProfile:Profile) =
let services = {
Logger = globalLogger
DbService = defaultDbService
EmailService = defaultEmailService
}
updateCustomerProfile services newProfile
(* ======================================================================
Approach 4. Dependency Injection -- OO Style
====================================================================== *)
module DependencyInjection =
open Domain
// -----------------------------------------------
// Pure core
// -----------------------------------------------
module Pure =
type ILogger = Infrastructure.ILogger
type Decision =
| NoAction
| UpdateProfileOnly of Profile
| UpdateProfileAndNotify of Profile * EmailMessage
let updateCustomerProfile (logger:ILogger) (newProfile:Profile) (currentProfile:Profile) =
if currentProfile <> newProfile then
logger.Info("Updating Profile")
if currentProfile.EmailAddress <> newProfile.EmailAddress then
let emailMessage = {
To = newProfile.EmailAddress
Body = "Please verify your email"
}
logger.Info("Sending email")
UpdateProfileAndNotify (newProfile, emailMessage)
else
UpdateProfileOnly newProfile
else
NoAction
// -----------------------------------------------
// Impure shell
// -----------------------------------------------
module Shell =
open Infrastructure
open Pure
type IServices = {
Logger : ILogger
DbService : IDbService
EmailService : IEmailService
}
// define a class with a constructor that accepts the dependencies
type MyWorkflow (services:IServices) =
member this.UpdateCustomerProfile (newProfile:Profile) =
let dbConnection = services.DbService.NewDbConnection()
let smtpCredentials = defaultSmtpCredentials
let logger = services.Logger
asyncResult {
// ----------- Impure ----------------
let! currentProfile = services.DbService.QueryProfile dbConnection newProfile.UserId
// ----------- pure ----------------
let decision = Pure.updateCustomerProfile logger newProfile currentProfile
// ----------- Impure ----------------
match decision with
| NoAction ->
()
| UpdateProfileOnly profile ->
do! services.DbService.UpdateProfile dbConnection profile
| UpdateProfileAndNotify (profile,emailMessage) ->
do! services.DbService.UpdateProfile dbConnection profile
do! services.EmailService.SendChangeNotification smtpCredentials emailMessage
}
/// Top-level "composition root"
let updateCustomerProfileApi (newProfile:Profile) =
let services = {
Logger = globalLogger
DbService = defaultDbService
EmailService = defaultEmailService
}
let myWorkflow = MyWorkflow(services)
myWorkflow.UpdateCustomerProfile newProfile
(* ======================================================================
Approach 4b. Dependency Injection -- Reader style
====================================================================== *)
type Reader<'env,'a> = Reader of action:('env -> 'a)
module Reader =
/// Run a Reader with a given environment
let run env (Reader action) =
action env // simply call the inner function
/// Create a Reader which returns the environment itself
let ask = Reader id
/// Map a function over a Reader
let map f reader =
Reader (fun env -> f (run env reader))
/// flatMap a function over a Reader
let bind f reader =
let newAction env =
let x = run env reader
run env (f x)
Reader newAction
/// Transform a Reader's environment.
/// Known as `withReader` in Haskell
let withEnv (f:'env2->'env1) reader =
Reader (fun env' -> (run (f env') reader))
[<AutoOpen>]
module ReaderCE =
type ReaderBuilder() =
member __.Return(x) = Reader (fun _ -> x)
member __.Bind(x,f) = Reader.bind f x
member __.Zero() = Reader (fun _ -> ())
member this.Combine (a,b) = Reader.bind b a
// the builder instance
let reader = ReaderBuilder()
module ReaderInjection =
open Domain
// -----------------------------------------------
// Pure core
// -----------------------------------------------
module Pure =
type ILogger = Infrastructure.ILogger
type Decision =
| NoAction
| UpdateProfileOnly of Profile
| UpdateProfileAndNotify of Profile * EmailMessage
let updateCustomerProfile (newProfile:Profile) (currentProfile:Profile) :Reader<ILogger,Decision> =
reader {
let! (logger:ILogger) = Reader.ask
let decision =
if currentProfile <> newProfile then
logger.Info("Updating Profile")
if currentProfile.EmailAddress <> newProfile.EmailAddress then
let emailMessage = {
To = newProfile.EmailAddress
Body = "Please verify your email"
}
logger.Info("Sending email")
UpdateProfileAndNotify (newProfile, emailMessage)
else
UpdateProfileOnly newProfile
else
NoAction
return decision
}
// -----------------------------------------------
// Impure shell WITHOUT using Reader for top-level IO
// -----------------------------------------------
module Shell_v1 =
open Infrastructure
open Pure
type IServices = {
Logger : ILogger
DbService : IDbService
EmailService : IEmailService
}
// Infrastructure services are passed in as a parameter
let updateCustomerProfile (services:IServices) (newProfile:Profile) :AsyncResult<unit,InfrastructureError> =
let dbConnection = services.DbService.NewDbConnection()
let smtpCredentials = defaultSmtpCredentials
let logger = services.Logger
asyncResult {
// ----------- impure ----------------
let! currentProfile = services.DbService.QueryProfile dbConnection newProfile.UserId
// ----------- pure ----------------
let decision =
Pure.updateCustomerProfile newProfile currentProfile
|> Reader.run logger
// ----------- impure ----------------
match decision with
| NoAction ->
()
| UpdateProfileOnly profile ->
do! services.DbService.UpdateProfile dbConnection profile
| UpdateProfileAndNotify (profile,emailMessage) ->
do! services.DbService.UpdateProfile dbConnection profile
do! services.EmailService.SendChangeNotification smtpCredentials emailMessage
}
/// Top-level "composition root"
let updateCustomerProfileApi (newProfile:Profile) =
let services = {
Logger = globalLogger
DbService = defaultDbService
EmailService = defaultEmailService
}
updateCustomerProfile services newProfile
// -----------------------------------------------
// Impure shell using Reader for top-level I/O
// -----------------------------------------------
module Shell_v2 =
open Infrastructure
open Pure
type IServices = {
Logger : ILogger
DbService : IDbService
EmailService : IEmailService
}
// first step in our mini-app
let getProfile (userId:UserId) :Reader<IServices, AsyncResult<Profile,InfrastructureError>> =
reader {
let! (services:IServices) = Reader.ask
let dbConnection = services.DbService.NewDbConnection()
return services.DbService.QueryProfile dbConnection userId
}
// last step in our mini-app
let handleDecision (decision:Decision) :Reader<IServices, AsyncResult<unit,InfrastructureError>> =
reader {
let! (services:IServices) = Reader.ask
let dbConnection = services.DbService.NewDbConnection()
let smtpCredentials = defaultSmtpCredentials
let action = asyncResult {
match decision with
| NoAction ->
()
| UpdateProfileOnly profile ->
do! services.DbService.UpdateProfile dbConnection profile
| UpdateProfileAndNotify (profile,emailMessage) ->
do! services.DbService.UpdateProfile dbConnection profile
do! services.EmailService.SendChangeNotification smtpCredentials emailMessage
}
return action
}
// Infrastructure services are passed in via a Reader
let updateCustomerProfile (newProfile:Profile) =
reader {
let! (services:IServices) = Reader.ask
let getLogger services = services.Logger
return asyncResult {
// ----------- impure ----------------
let! currentProfile =
getProfile newProfile.UserId
|> Reader.run services
// ----------- pure ----------------
let decision =
Pure.updateCustomerProfile newProfile currentProfile
|> Reader.withEnv getLogger
|> Reader.run services
// ----------- impure ----------------
do! (handleDecision decision) |> Reader.run services
}
}
/// Top-level "composition root"
let updateCustomerProfileApi (newProfile:Profile) =
let services = {
Logger = globalLogger
DbService = defaultDbService
EmailService = defaultEmailService
}
(updateCustomerProfile newProfile)
|> Reader.run services
(* ======================================================================
Approach 5. Dependency Interpretation
====================================================================== *)
//----------------------------------------
// A generic program that does not know about specific instructions
//----------------------------------------
module GenericProgram =
// 1. Define a instruction interface that contains a "map"
type IInstruction<'a> =
abstract member Map : ('a->'b) -> IInstruction<'b>
// 2, Use the interface in the Program type
type Program<'a> =
| Instruction of IInstruction<Program<'a>>
| Stop of 'a
// 3. Define the corresponding "bind"
module Program =
let rec bind f program =
match program with
| Instruction inst ->
inst.Map (bind f) |> Instruction
| Stop x -> f x
// 4. Define the computation expression
type ProgramBuilder() =
member __.Return(x) = Stop x
member __.ReturnFrom(x) = x
member __.Bind(x,f) = Program.bind f x
member __.Zero() = Stop ()
member this.Combine (a:Program<_>, b:unit->Program<_>) = Program.bind b a
member this.Combine (a:Program<_>, b:Program<_>) = Program.bind (fun () -> b) a
// and the builder instance
let program = ProgramBuilder()
//----------------------------------------
// A specific program based on the common requirements
//----------------------------------------
module DependencyInterpretation =
open Domain
open GenericProgram
// -----------------------------------------------
// Instructions used in the pure core
// -----------------------------------------------
module PureInstructions =
type LoggerInstruction<'a> =
| LogInfo of string * next:(unit -> 'a)
| LogError of string * next:(unit -> 'a)
interface IInstruction<'a> with
member this.Map f =
match this with
| LogInfo (str,next) ->
LogInfo (str,next >> f)
| LogError (str,next) ->
LogError (str,next >> f)
:> IInstruction<_>
// helpers to use within the computation expression
let logInfo str = Instruction (LogInfo (str,Stop))
let logError str = Instruction (LogError (str,Stop))
// -----------------------------------------------
// Pure core
// -----------------------------------------------
module Pure =
open PureInstructions
type Decision =
| NoAction
| UpdateProfileOnly of Profile
| UpdateProfileAndNotify of Profile * EmailMessage
let updateCustomerProfile (newProfile:Profile) (currentProfile:Profile) :Program<Decision> =
if currentProfile <> newProfile then program {
do! logInfo("Updating Profile")
if currentProfile.EmailAddress <> newProfile.EmailAddress then
let emailMessage = {
To = newProfile.EmailAddress
Body = "Please verify your email"
}
do! logInfo("Sending email")
return UpdateProfileAndNotify (newProfile, emailMessage)
else
return UpdateProfileOnly newProfile
}
else program {
return NoAction
}
// -----------------------------------------------
// Instructions used in the impure shell
// -----------------------------------------------
module ImpureInstructions =
// 1. Define the set of instructions we want to support, and their map
type DbInstruction<'a> =
| QueryProfile of UserId * next:(Profile -> 'a)
| UpdateProfile of Profile * next:(unit -> 'a)
interface IInstruction<'a> with
member this.Map f =
match this with
| QueryProfile (userId,next) ->
QueryProfile (userId,next >> f)
| UpdateProfile (profile,next) ->
UpdateProfile (profile, next >> f)
:> IInstruction<_>
type EmailInstruction<'a> =
| SendChangeNotification of EmailMessage * next:(unit-> 'a)
interface IInstruction<'a> with
member this.Map f =
match this with
| SendChangeNotification (message,next) ->
SendChangeNotification (message,next >> f)
:> IInstruction<_>
// helpers to use within the computation expression
let queryProfile userId = Instruction (QueryProfile(userId,Stop))
let updateProfile profile = Instruction (UpdateProfile(profile,Stop))
let sendChangeNotification message = Instruction (SendChangeNotification(message,Stop))
// -----------------------------------------------
// Impure shell
// -----------------------------------------------
module Shell =
open Pure
open ImpureInstructions
let getProfile (userId:UserId) :Program<Profile> =
program {
return! queryProfile userId
}
let handleDecision (decision:Decision) :Program<unit> =
match decision with
| NoAction ->
program.Zero()
| UpdateProfileOnly profile ->
updateProfile profile
| UpdateProfileAndNotify (profile,emailMessage) ->
program {
do! updateProfile profile
do! sendChangeNotification emailMessage
}
let updateCustomerProfile (newProfile:Profile) =
program {
let! currentProfile = getProfile newProfile.UserId
let! decision = Pure.updateCustomerProfile newProfile currentProfile
do! handleDecision decision
}
// -----------------------------------------------
// The interpreter
// -----------------------------------------------
module Interpreter =
open PureInstructions
open ImpureInstructions
open Infrastructure
// modular interpreter for LoggerInstruction
let interpretLogger interpret inst =
match inst with
| LogInfo (str, next) ->
globalLogger.Info str
let newProgramAS = next() |> asyncResult.Return
interpret newProgramAS
| LogError (str, next) ->
globalLogger.Error str
let newProgramAS = next() |> asyncResult.Return
interpret newProgramAS
// modular interpreter for DbInstruction
let interpretDbInstruction (dbConnection:DbConnection) interpret inst =
match inst with
| QueryProfile (userId, next) ->
let profileAS = defaultDbService.QueryProfile dbConnection userId
let newProgramAS = (AsyncResult.map next) profileAS
interpret newProgramAS // returns an :AsyncResult<'a,InfrastructureError>
| UpdateProfile (profile, next) ->
let unitAS = defaultDbService.UpdateProfile dbConnection profile
let newProgramAS = (AsyncResult.map next) unitAS
interpret newProgramAS
// modular interpreter for EmailInstruction
let interpretEmailInstruction (smtpCredentials:SmtpCredentials) interpret inst =
match inst with
| SendChangeNotification (message, next) ->
let unitAS = defaultEmailService.SendChangeNotification smtpCredentials message
let newProgramAS = (AsyncResult.map next) unitAS
interpret newProgramAS
let interpret program =
// 1. get the extra parameters and partially apply them to make all the interpreters
// have a consistent shape
let smtpCredentials = defaultSmtpCredentials
let dbConnection = defaultDbService.NewDbConnection()
let interpretDbInstruction' = interpretDbInstruction dbConnection
let interpretEmailInstruction' = interpretEmailInstruction smtpCredentials
// 2. define a recursive loop function. It has signature:
// AsyncResult<Program<'a>,InfrastructureError>) -> AsyncResult<'a,InfrastructureError>
let rec loop programAS =
asyncResult {
let! program = programAS
return!
match program with
| Instruction inst ->
match inst with
| :? LoggerInstruction<Program<_>> as inst -> interpretLogger loop inst
| :? DbInstruction<Program<_>> as inst -> interpretDbInstruction' loop inst
| :? EmailInstruction<Program<_>> as inst -> interpretEmailInstruction' loop inst
| _ -> failwithf "unknown instruction type %O" (inst.GetType())
| Stop value ->
value |> asyncResult.Return
}
// 3. start the loop
let initialProgram = program |> asyncResult.Return
loop initialProgram
/// Top-level "composition root"
let updateCustomerProfileApi (newProfile:Profile) =
Shell.updateCustomerProfile newProfile
|> interpret
|> Async.RunSynchronously
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment