A workflow pattern in F#
Published: 2023-12-05 09:37:09

{% embed https://github.com/WesleySkeen/fsharp-workflow-patterns.git %}

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](https://github.com/dotnet/fsharp/blob/main/src/FSharp.Core/seq.fs#L906-906). 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` - Take `ExternalServiceRequest` 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.