F# Domain Modelling

Over the last few weeks my team and I have been setting up a new QA environment to validate a rather large piece of ongoing work. As part of this I thought it would be prudent to automate our health checks so we can ensure the environment has launched successfully each morning and is ready for use.

Previously I'd write such a tool in Python (leading to many many unit tests & grumbles about maintainability) or C# (which would entail lots of new DTOs and potential for overengineering a relatively simple application) however my recent exposure to F# led me to use the language with great results!

F# has plenty of strengths, many outlined on this outstanding website: F# for Fun and Profit, however I'm increasingly finding the most useful elements are discriminated unions, record types and pattern matching. These 3 combined allow for rapid domain modelling that helps to abstract away complexity and informs terse business logic.

Discriminated Unions

The starting point of our health check application was adding a small set of discrimiated unions; these are a form of algebraic data type & I won't pretend to understand the underlying mathematics behind them, however for these purposes they can be seen as "enums on steriods".

Discriminate unions allow you to define types that can be one of a series of permutations. In the below example we have a simple implementation of such a type defining the status of a health check:

type Status = 
    | Online 
    | Unresponsive of string
    | Missing of string
    | NotChecked of string
    | Ignored

Online and Ignored are simple options, very similar to enum values. Unresponsive, Missing and NotChecked however can only be defined with a string. This allows us to, when checking if the environment is online, return both the state like Unresponsive and a message indicating what may be wrong.

So later on when we've built the code to send a request checking the endpoint we can process the result like so with pattern matching.

// getResponse is a wrapper around a HttpClient call
match getResponse(healthCheckUrl).StatusCode with 
| HttpStatusCode.OK -> Online
| HttpStatusCode.NotFound -> Missing "Could not find the healthCheckUrl"
| statusCode -> Unresponsive("Status code - " + statusCode.ToString())

This logic returns a type of "Status"; which could be any of the permutations defined in our union above.

Already here we're using the powerful discriminated unions logic to build a much cleaner abstraction around our business logic; leaving aside the standard F# syntax, the pattern matching statement is almost demonstrating a domain specific language (DSL) already.

Record Types

Now we've started building discrimated unions; we can start to combine these with record types to create an even more powerful structure for our domain. Record types can be seen initially as DTOs; they define a set of properties that must be set. None of them are nullable (though you can use Option types) and they can be inferred by the compiler in most cases.

Below are some additions to our model:

type Certainty = Definite of bool | Unsure
type CheckType = HealthCheck | RootUrlCheck | Ignore
type AwsServiceType = ElasticSearch | Route53Entry | ElasticCache | S3Bucket

type InternalServiceRecord = {name:string; uptoDate:Certainty; checkType:CheckType; url:string}
type AwsDependencyRecord = {name:string; serviceType:AwsServiceType}
type MissingDependencyRecord = {name:string; whyMissing:string}

type ServiceType = 
    | InternalService of InternalServiceRecord 
    | AwsDependency of AwsDependencyRecord 
    | MissingDependency of MissingDependencyRecord

The Certainty type can be used to define how sure we are of a state (can be Definite true, Definite false or Unsure). Initially for internal services I am manually checking whether they are on the latest version, so for now we'll encode that in our model.

The CheckType type defines which sort of check should be run on an internal service. HealthCheck ensures a specific route is checked, RootUrlCheck is for services that don't offer this, so we check the root host name and Ignore is for a service that we can't currently check with a http request.

The AwsServiceType type is a list of possible AWS dependencies (obviously not complete!); we can use this information later to perform service specific checks against our AWS account.

Now onto the record types themselves; we have three defined currently named InternalServiceRecord, AwsDependencyRecord and MissingDependencyRecord. These contain the details we need to start checking whether the environment is stable. These are then collected under the ServiceType discriminated union.

What the ServiceType union allows us to do is list all dependencies within the same set and use pattern matching to determine what to do with each when processing.

Now the model is defined the list can look like the one defined below. This is my favourite part; we can see here the model really coming together.

let services = [
    InternalService {name="API 1"; uptoDate=Definite true; checkType=HealthCheck;  url="api1.sample.com"}
    InternalService {name="API 2"; uptoDate=Unsure;        checkType=RootUrlCheck; url="api2.sample.com"}

    AwsDependency {name="mybucket";  serviceType=S3Bucket}
    AwsDependency {name="mycluster"; serviceType=ElasticSearch}

    MissingDependency {name="API 3"; whyMissing="Build missing from CI server"}
]

Pattern Matching

I've briefly shown pattern matching above when processing discriminated unions; we'll go into more detail here. Pattern matching is similar to a switch statement in C# with 2 vital differences. 1: you can match on any type, filtering based on properties matched and 2: it warns you when you've not matched all possible permutations of the provided type. This is very useful when combined with discriminated unions as you can be sure all cases are dealt with.

Below is an example of how you may process the above services list in our model. If you've not seen |> before; this is the "pipe-forward operator" which takes the preceding value and passes it as the last parameter into the next function. So here it passed services into List.map(fun, list) as the list parameter.

services
|> List.map(fun service -> match service with
                            | InternalService s -> match s.checkType with
                                                    | HealthCheck -> healthCheckUrl s.url
                                                    | RootUrlCheck -> rootCheckUrl s.url
                                                    | Ignore -> outputIgnored s
                            | AwsDependency s -> match s.serviceType with
                                                | ElasticSearch -> checkElasticSearchExists s.name
                                                | Route53Entry -> checkEntryExists s.name
                                                | ElasticCache -> checkCacheExists s.name
                                                | S3Bucket -> checkBucketExists s.name
                            | MissingDependency s -> outputMissing s)

You can see now we've got a succinct set of logic that takes a list of dependencies and processes them based on which types they are defined as. If each of the functions like healthCheckUrl returned a Status discriminated union as we defined at the start; we'd be left with a list of status relating to each service that has been checked.

Summary

Discriminated Unions & Record Types can be combined very quickly into a powerful domain model that can be processed rapidly and with much less code using Pattern Matching. The tool we built was successful and the brevity of the logic made it much easier to maintain.