A workflow pattern in F#
Wesley Skeen
Posted on December 5, 2023
During a lift, refactor, shift (C# -> F#), I came across a feature in our codebase that was a good candidate for a workflow pattern. It contained the following steps and was fairly procedural in design. Basically the code done some validation and if the validation was successful we would call some external service. Each service had its own steps for validation. There was a lot of mutation and which resulted in huge side effects that made the code hard to debug and read. The layout was something like
var canCallServiceA = false
var canCallServiceB = false
var canCallServiceC = false
if email is poulated and phone number is populated
canCallServiceA = true
else
canCallServiceA = false
if phone number is populated and canCallServiceA = false
canCallServiceB = true
else
canCallServiceB = false
if phone number is not populated and canCallServiceB = true
canCallServiceC = false
else
canCallServiceC = true
This is a really simplistic version of what is actually going on in the code base.
Since we didn't want to straight up migrate and inherit the design of the current implementation, we came up with a pattern to convert the logic into a clean workflow pattern.
Seq functions
We heavily relied on Seq functions to get this one across the line, in particular Seq.Fold. This allows us to update state while executing the workflow. Here is basic example of a workflow to demonstrate Seq.Fold
.
let shouldContinueWorkflow (input: int) = input <> 8
let executeWorkflow =
let inputs = [1; 2; 3; 4; 5; 6; 7; 8; 9; 10]
let shouldExecuteCurrentStep = true
inputs
|> Seq.fold (fun (shouldExecuteCurrentStep: bool) (x: int) ->
match shouldExecuteCurrentStep with
| true ->
let shouldContinue = x |> shouldContinueWorkflow
match shouldContinue with
| true ->
printfn $"Continuing from input is {x}"
| false ->
printfn $"Should not continue as input is {x}"
shouldContinue
| false -> false
) shouldExecuteCurrentStep
In the above example,
- Iterate over the input of an array of integers [1...10]
- Check if we should continue (defaulted to true)
- Check if the current iteration of input is not equal to 8
- Set the state variable
shouldExecuteCurrentStep
to the result of this check
If we run the above program we get the output
Continuing as input is 1
Continuing as input is 2
Continuing as input is 3
Continuing as input is 4
Continuing as input is 5
Continuing as input is 6
Continuing as input is 7
Should not continue as input is 8
This is a basic example but next we will go into something that matches task more closely
Detailed workflow example
Models
We are going to use more complex objects for
- Passing in request data to the workflow functions
- Returning data from the workflow validation
- What we pass into the workflow to iterate over
Request
In a real world scenario we would be dealing with real data and not an array of 1 to 10. To make this example seem closer to a real work scenario, I am going to imagine the a request will come in that contains real world data. This is the data we will use to decide if a service is called and if we should continue
type ExternalServiceRequest = {
EmailAddress: string
PhoneNumber: string
PostCode: string
}
Result from the validation step
This will hold the state data from the validation step that gets executed on each iteration
type ExternalServiceCanExecuteResult = {
CanExecute: bool
ShouldContinue: bool
}
-
CanExecute
- Can this service be called -
ShouldContinue
- Should we continue with other workflow steps after calling the service
External service
These models take the input of ExternalServiceRequest
and have 2 functions
-
CanExecute
- TakeExternalServiceRequest
and decide if we should call the service -
Execute
- Call the service. For now we are just mocking the result of this
Since we have several services that we want to call, it would make sense that these services would inherit from some sort of base class. I used OO to achieve this and created an abstract class.
type IExternalService =
abstract member CanExecute : ExternalServiceRequest -> ExternalServiceCanExecuteResult
abstract member Execute : ExternalServiceRequest -> unit
Here are a couple of implementations of this abstract class. Please see the attached git repo to view all the implementations
let serviceA = {
new IExternalService with
member _.CanExecute request: ExternalServiceCanExecuteResult =
match
request.EmailAddress.Length > 0,
request.PhoneNumber.Length > 0,
request.PostCode.Length > 0 with
| true, true, true -> { CanExecute = true; ShouldContinue = false }
| _, _, _ -> { CanExecute = false; ShouldContinue = true }
member _.Execute request = printfn "Executing service A"
}
Each service implements the abstract class and it's functions.
Taking serviceA
as an example as its CanExecute
function is more complex. This fictional service requires email, phone and postcode to be populated in order to execute. If all are populated, then we can call this service and there is no need to call any other service as we get all the required info from this service. If any of the fields are not populated then we do not call this service, but we can continue to call the next service in the workflow
let serviceB = {
new IExternalService with
member _.CanExecute request: ExternalServiceCanExecuteResult =
match request.PostCode.Length > 0 with
| true -> { CanExecute = true; ShouldContinue = true }
| false -> { CanExecute = false; ShouldContinue = true }
member _.Execute request = printfn "Executing service B"
}
serviceB
is another example, but this only requires postcode to be populated. If postcode is populated then we can call this service, otherwise we continue the workflow.
Here are the rest of the implementations
let serviceC = {
new IExternalService with
member _.CanExecute request: ExternalServiceCanExecuteResult =
match request.EmailAddress.Length > 0 with
| true -> { CanExecute = true; ShouldContinue = true }
| false -> { CanExecute = false; ShouldContinue = true }
member _.Execute request = printfn "Executing service C"
}
let serviceD = {
new IExternalService with
member _.CanExecute request: ExternalServiceCanExecuteResult =
match request.PhoneNumber.Length > 0 with
| true -> { CanExecute = true; ShouldContinue = true }
| false -> { CanExecute = false; ShouldContinue = true }
member _.Execute request = printfn "Executing service D"
}
Executing the workflow
First we have our collection of services we want to call
let externalServices: IExternalService seq =
[
serviceA
serviceB
serviceC
serviceD
]
Here is the full workflow execution using the above services collection
let executeWorkflow
(request: ExternalServiceRequest) =
let shouldExecuteCurrentStep = true
externalServices
|> Seq.fold (fun (shouldExecuteCurrentStep: bool) (externalService: IExternalService) ->
match shouldExecuteCurrentStep with
| true ->
let result = request |> externalService.CanExecute
match result.CanExecute with
| true -> externalService.Execute request
| false -> printfn $"Skipping {externalService.GetType().Name}"
result.ShouldContinue
| false -> false
) shouldExecuteCurrentStep
Moving to a pattern like this made the code more concise and readable. One of the big things we are striving for is developer experience. A big factor in this is how we write and structure our code. If a developer looks at the codebase we want them to feel comfortable and confident that they can contribute.
Posted on December 5, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.