DEV Community

Wesley Skeen
Wesley Skeen

Posted on

A workflow pattern in F#

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
  • 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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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"
    }
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

Executing the workflow

First we have our collection of services we want to call

let externalServices: IExternalService seq =
    [
        serviceA
        serviceB
        serviceC
        serviceD
    ]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)