Refactoring Boolean Book Keeping with the Help of F#

Zaymon Foulds-Cook - June 17, 2020

The Problem

Recently I have been working on an integration within one of our apps:

Initial Requirements

  • When a new invitation is generated we need to create a record of it.
  • A scheduled task loads all the pending invitations and sends them out.

To start tracking invitations I created a table with a schema like this:

type InvitationRecord =
    { InvitationId: Guid
      ...
      InvitationSent: bool }

Starting off simple this is okay. The scheduled task simply loads all invitations where InvitationSent = False and then sends an email for each one.

Once that was working I addressed the next requirement:

  • Invitations need to be re-sent on demand

Changing the code we add a new flag to our record and the corresponding column to our database:

type InvitationRecord =
    { InvitationId: Guid
      ...
      InvitationSent: bool
      MarkedForReSending: bool }

Now when the invitation needs to be re-sent we check InvitationSent = True and mark it for re-sending. If it hasn't been processed yet then it's a noop. Then the scheduled task queries all invitation that are either not processed or marked for re-sending

Let's look at the next requirement:

- Invitations can be withdrawn
- Invitations can't be completed if they are withdrawn

Again we can add another flag:

type InvitationRecord =
    { InvitationId: Guid
      ...
      InvitationSent: bool
      MarkedForReSending: bool
      Withdrawn: bool }

Now when querying invitations to send out, we check for:

   (InvitationSent = False OR MarkedForReSending = TRUE)
   AND Withdrawn = False

A final requirement for this task.

  • Mark when Invitations are completed

You can see where this is going:

type InvitationRecord =
    { InvitationId: Guid
      ...
      InvitationSent: bool
      MarkedForReSending: bool
      Withdrawn: bool
      SignupComplete: bool }

Now this is getting ridiculous. It's too hard to know what states are valid at different times. This is what I refer to as boolean book keeping.

The Answer

So how can we simplify this? Here's where the compiler and F# come in. Currently we are operating on a combination of 4 boolean variables. That means there are 2^n where n is 4 2^4 = 16 possible combinations of these boolean variables!

Now it should be noted that not all combinations are going to be valid. The motivation for the following refactor is the idea of:

Making invalid states unrepresentable

First I create a function that takes an Invitation and matches on all the booleans:

let toState (x: InvitationRecord) =
    match x.InvitationSent, x.Withdrawn, x.ReSendInvitation, x.SignupComplete with
    ...

Now the compiler is instantly going to complain that I haven't exhausted all possible combinations. Incomplete pattern matches on this expression. So let's start our elimination process.

First I add:

let toState (x: InvitationRecord) =
    match x.InvitationSent, x.Withdrawn, x.ReSendInvitation, x.SignupComplete with
    | _, _, _, true -> "Completed"

I know that no matter what values are in the other columns if SignupComplete is true in isolation then the process is complete.

The compiler is still yelling at me that I haven't matched all cases so let's encode the case where we need to send the initial invitation: InvitationSent = False AND Withdrawn = False

let toState (x: InvitationRecord) =
    match x.InvitationSent, x.Withdrawn, x.ReSendInvitation, x.SignupComplete with
    | _, _, _, true -> "Completed"
    | false, false, _, _ -> "Awaiting Invitation Sending"

Now let's encode the case where the invitation has been withdrawn. We know logically that if the invitation has been withdrawn then the invitation will not be complete:

let toState (x: InvitationRecord) =
    match x.InvitationSent, x.Withdrawn, x.ReSendInvitation, x.SignupComplete with
    | _, _, _, true -> "Completed"
    | false, false, _, _ -> "Awaiting Invitation Sending"
    | _, true, _, false -> "Withdrawn"

Okay now we are getting somewhere but the compiler is still not satisfied. What about when the invitation needs to be Re-Sent?

let toState (x: InvitationRecord) =
    match x.InvitationSent, x.Withdrawn, x.ReSendInvitation, x.SignupComplete with
    | _, _, _, true -> "Completed"
    | false, false, _, _ -> "Awaiting Invitation Sending"
    | _, true, _, false -> "Withdrawn"
    | _, false, true, _ -> "Awaiting Re-Sending"

Now my brain hurting a little and my sensibilities are telling me I should be stopping here. But the compiler is still complaining:

Incomplete pattern matches on this expression. For example the value (_, _, false, _) may indicate a case not covered by the pattern(s).

Of course! There's still the case where the invitation has been sent and there is nothing to do.

let toState (x: InvitationRecord) =
    match x.InvitationSent, x.Withdrawn, x.ReSendInvitation, x.SignupComplete with
    | _, _, _, true -> "Completed"
    | false, false, _, _ -> "Awaiting Invitation Sending"
    | _, true, _, false -> "Withdrawn"
    | _, false, true, _ -> "Awaiting Re-Sending"
    | true, false, false, _ -> "Invitation Sent"

Now the compiler is satisfied and we have reduced 16 combinations down to a reasonable 5! There's still some more we can do though, since Awaiting Invitation Sending and Awaiting Re-Sending are the same logical action we can condense them!

let toState (x: InvitationRecord) =
    match x.InvitationSent, x.Withdrawn, x.ReSendInvitation, x.SignupComplete with
    | _, _, _, true -> "Completed"
    | false, false, _, _    | _, false, true, _ -> "Awaiting Invitation Sending"    | _, true, _, false -> "Withdrawn"
    | true, false, false, _ -> "Invitation Sent"

Much better, now we have successfully reduced our state complexity to 4 cases. Time to delete this function and encode this in the type system.

type InvitationStatus =
    | WaitingToSendInvitation
    | InvitationSent
    | Withdrawn
    | SignupComplete

Now we can simply store a single value in a new status column:

type InvitationRecord =
    { InvitationId: Guid
      ...
      Status: InvitationStatus }

When loading invitations we can just query invitations that are in the WaitingToSendInvitation status. This system makes it much easier to check different states.

The reason I didn't do this modeling initially is because I was still in the process of discovering the requirements and working out how the system would fit together. Adding booleans is a valid approach of incrementally adding functionality but there comes a point where the burden is high, so use the compiler to help you refactor them away!

Bonus Section - Defined State Transitions

There's something interesting we can do now to make our modeling safer. We can define the transitions between states to better reflect the domain requirements.

let (==>) x y = x , y // Custom operator to tuple elements

let transitions =
    Map [
        WaitingToSendInvitation ==> InvitationSent
        WaitingToSendInvitation ==> Withdrawn
        InvitationSent ==> WaitingToSendInvitation
        InvitationSent ==> Withdrawn
        InvitationSent ==> Complete
        Withdrawn ==> Withdrawn
        Complete ==> Complete
    ]

The usage of this is left as a thought exercise but you see that by defining the transitions between states, and checking if a transformation is allowed before an operation, we can never end up an invalid state. For example we can't go from Complete to WaitingToSendInvitation.


Zaymon Foulds-Cook

Articles and Musings by Zaymon Foulds-Cook ⚡️
Github Twitter