|
(* =================================== |
|
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%i@example.com" 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 |
|
|