F# Discriminated Unions with SQL Enumerations

đź’ˇ
This post is part of F# Advent Calendar 2024.

Having worked extensively with both C# (including Entity Framework) and F#, I've found that F# allows us to elegantly model the business domain while seamlessly bridging the gap between code and database. In this post, I'll outline key principles guiding my team's approach—Domain-Driven Design (DDD), database-first design, and enumerations over booleans—before demonstrating how F# can persist and materialize discriminated unions in OLTP systems using Dapper.FSharp.

Here are some principles that have guided the approach our team has had to application data.

Domain-Driven Design (DDD) as the Foundation

Types in F# provide unmatched clarity when modeling a domain. By aligning our types closely with business concepts, we get a codebase that communicates intention clearly while reducing complexity. A domain that is the code ensures the business logic is robust, type-safe, and easy to reason about. This also allows the team to operate efficiently, as there is less cognitive overhead when moving between features.

Database-First Design and Normalization

We favor database-first design over code-first, leading to a cleaner, normalized schema. This separation of concerns simplifies database maintenance and keeps F# code expressive and elegant. Small, well-normalized tables prevent unintended complexity and make the code interacting with them easier to follow. In my experience I have found that wider tables lead to more unintended complexity over time, a problem that I believe is exacerbated by code-first DB design.

Enumerations Over Booleans

Booleans are tempting for their simplicity, but enumerations (or discriminated unions in F#) are superior for expressing domain precision. They communicate intent, reduce invalid states, and align with F#'s strengths, such as exhaustive pattern matching.

For a deeper dive into this idea of preferring enumeration, I highly recommend Kyle Shevlin's article Enumerate, Don’t Booleanate. As the article highlights, booleans often fail to capture the precision of a domain and can also lead to unintended complexity, whereas a well-chosen enumeration avoids ambiguity.

Designing the Domain Database-First

We’ll use Dapper.FSharp as a lightweight ORM to bridge our normalized database with F# types. Let’s design an example with a User and a Subscription.

F# Domain Model

We model the User and Subscription types cleanly, keeping Subscription separate to allow for normalized membership tiers:

[<CLIMutable>]
type User =
    { ID: Guid
      Email: string }

type Tier =
    | Free
    | Trial
    | Full

[<CLIMutable>]
type Subscription =
  { ID: Guid
    UserID: Guid
    Tier: Tier }
    

Corresponding Database Schema

The database schema directly reflects the F# domain model. Notice how the Tier enum aligns with the F# discriminated union:

CREATE TABLE User
(
    ID VARCHAR(255) UNIQUE NOT NULL PRIMARY KEY,
    Email VARCHAR(255) UNIQUE NOT NULL,
);

CREATE TABLE Subscription
(
    ID VARCHAR(255) UNIQUE NOT NULL PRIMARY KEY,
    UserID VARCHAR(255) NOT NULL,
    Tier ENUM ('free', 'trial', 'full') NOT NULL,

    FOREIGN KEY (UserID) REFERENCES User (ID) ON DELETE CASCADE
);

Overall we avoid the temptation of adding more columns to the user. Perhaps this example is too simple, but we'd apply the principle as consistently as possible with new domains.

Bridging SQL Enums and F# Discriminated Unions with Type Handlers

This is an important piece. We want there to be no friction between our powerful F# type and the more simple DB string enum. To seamlessly map SQL enums (free, trial, full) to F#'s Tier discriminated union, we use Dapper TypeHandlers. Registering type handlers allows for clean materialization of data without manual conversion.

Here’s the type handler definition:

type TierHandlers() =
    inherit SqlMapper.TypeHandler<Tier>()

    override _.SetValue(param, value) =
        param.Value <- value |> string |> _.toLower()

    override _.Parse value =
        match value with
        | "free" -> Free
        | "trial" -> Trial
        | "full" -> Full
        | _ -> failwith "Unknown Tier value was given"

let registerTypeHandlers () =
    SqlMapper.AddTypeHandler(TierHandlers())

Continue to add any new type handlers to registerHandlers and call registerHandlers() at bootstrap.

Querying the Database with Dapper.FSharp

With our domain and database in sync, querying and materializing the data becomes seamless. Let’s fetch a user’s subscription and return features based on their tier:

let apiHandlerFunction (ctx: HttpContext) =
    async {
        let userID = // get user id from context

        let subscription =
            select {
                for u in userTable do
                    innerJoin s in subscriptionTable on (u.ID = s.UserID)
                    where (u.ID = userID)
                    selectAll
            }
            |> conn(ctx).SelectAsync<User, Subscrption>
            |> Async.AwaitTask
            |> Async.RunSynchronously
            |> Seq.map snd
            |> Seq.head

        return!
          match subscription.Tier with
          | Free ->
              {| Features = [] |}
          | Trial ->
              {| Features = ["abc"; "def"] |}
          | Full ->
              {| Features = ["abc"; "def"; "ghi"] |}
      }

This solution demonstrates the strengths of F# and Dapper.FSharp working in harmony:

  1. Type-Safe Data Representation:
    SQL enums are cleanly mapped to F# discriminated unions using type handlers, ensuring that data is seamlessly materialized into precise, domain-driven types.
  2. Exhaustive Pattern Matching:
    The Tier discriminated union allows for clear, exhaustive pattern matching, ensuring that all cases (Free, Trial, Full) are handled explicitly without risk of invalid states or overlooked conditions.
  3. Clean and Minimalist Querying:
    With Dapper.FSharp, SQL queries remain concise and expressive, leveraging F#'s functional programming features to interact with normalized tables efficiently. There are no magic strings and everything is type safe since the tables were originally defined with our F# types.
  4. Separation of Concerns:
    By keeping User and Subscription separate, the design adheres to database normalization principles, preventing unnecessary duplication and complexity.

This combination of clean type modeling, type-safe bridging, and robust querying results in a solution that is both elegant and practical. F#'s strengths—clarity, expressiveness, and safety—are fully utilized to create a system that is easy to maintain, reason about, and extend.

Final Thoughts

From my experience, this approach has proven a winning formula for keeping codebases clean, expressive, and aligned with the business domain. Small, normalized tables—even at the cost of more F# types—promote clarity and reduce the risk of complexity creeping in. As the codebase matures, these types can be extended further with private active patterns and computed properties.

Key takeaways:

  • Database-first design ensures a clean, normalized schema.
  • Enumerations over booleans result in a domain that’s precise and intentional.
  • Dapper.FSharp bridges the gap elegantly, especially when paired with F#’s powerful type system.

Finally, representing the domain explicitly allows us to leverage F#’s exhaustive pattern matching—a cornerstone of Domain-Driven Design. With a little setup, F# lets us seamlessly persist and materialize rich domain types, while keeping the code readable and robust.


Some helpful links:

Enumerate, Don’t Booleanate | Kyle Shevlin
We learn how to use booleans early and often, but they are not the right tool for the job sometimes. A enum may solve our problems with greater precision.
GitHub - Dzoukr/Dapper.FSharp: Lightweight F# extension for StackOverflow Dapper with support for MSSQL, MySQL, PostgreSQL, and SQLite
Lightweight F# extension for StackOverflow Dapper with support for MSSQL, MySQL, PostgreSQL, and SQLite - Dzoukr/Dapper.FSharp