F# Discriminated Unions with SQL Enumerations
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:
- 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. - Exhaustive Pattern Matching:
TheTier
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. - 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. - Separation of Concerns:
By keepingUser
andSubscription
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 by Kyle Shevlin
- Dapper.Fsharp by Roman ProvaznĂk
- Effective SQL: 61 Specific Ways to Write Better SQL by John Viescas