F# Domain Modeling: How to Make Illegal States Unrepresentable

Have you ever spent hours debugging a NullReferenceException or tracking down why an “active” user was somehow also “deleted” in your database? If you have worked with traditional Object-Oriented Programming (OOP) languages like C# or Java, you have likely encountered the struggle of keeping your business logic in sync with your data structures. You write defensive code, add hundreds of if statements, and pray that the unit tests catch the edge cases.

What if the compiler could do that work for you? What if your code was structured in a way that it was literally impossible to represent an invalid state? In the world of F#, this isn’t a dream; it is the standard way of working. This approach is called Functional Domain Modeling.

In this comprehensive guide, we are going to dive deep into how F# uses its powerful type system to model complex business domains. Whether you are a seasoned C# developer looking to improve your architectural skills or a complete beginner to functional programming, this article will show you how to write safer, more concise, and more maintainable code.

The Problem: The “Everything is Possible” Trap

In most mainstream languages, we represent data using classes. Classes are flexible, but they are often too flexible. Consider a simple “Contact” class in a typical C# application:


// C# Example of a "Fragile" Model
public class Contact {
    public string Name { get; set; }
    public string Email { get; set; }
    public bool IsEmailVerified { get; set; }
    public string PhoneNumber { get; set; }
}

At first glance, this looks fine. But let’s look closer. What happens if Name is an empty string? What if IsEmailVerified is true, but the Email property is null? What if the user provides both an email and a phone number, but your business rule says they should only have one primary contact method?

To fix this, we usually write “Validation” logic elsewhere in the service layer. We end up with code that looks like this:


if (contact.IsEmailVerified && string.IsNullOrEmpty(contact.Email)) {
    throw new Exception("Invalid state!");
}

The problem is that the Type System is lying to you. It says a Contact is just a bag of properties, and it’s up to you to remember all the rules. In F#, we turn this around. We use the type system to define the rules so that the compiler refuses to run code that breaks them. This is the essence of “Domain Driven Design” (DDD) in a functional context.

1. The Building Blocks: Records and Discriminated Unions

F# gives us two primary tools for modeling data: Records and Discriminated Unions. Understanding these is the first step toward mastering F#.

Records: Modeling “And” Relationships

A Record is a collection of named values. It is similar to a class but with two major differences: they are immutable by default and they have structural equality.


// Defining a Record in F#
type Person = {
    FirstName: string
    LastName: string
    Age: int
}

// Creating an instance
let user = { FirstName = "Alice"; LastName = "Smith"; Age = 30 }

// Structural Equality: This will be true
let user2 = { FirstName = "Alice"; LastName = "Smith"; Age = 30 }
let areEqual = (user = user2) 

In F#, if two records have the same data, they are considered the same object. This eliminates an entire class of bugs related to object reference comparisons. Since they are immutable, you don’t have to worry about some other part of your code changing the Age property behind your back.

Discriminated Unions: Modeling “Or” Relationships

This is F#’s “killer feature.” While Records represent a collection of data (FirstName and LastName), Discriminated Unions (DUs) represent a choice between different possibilities (Email or Phone).


type ContactMethod =
    | Email of string
    | Phone of string
    | Post of string

A ContactMethod can be an Email, OR a Phone number, OR a Postal address. It cannot be all three at once, and it cannot be none of them. This is how we eliminate invalid states. You don’t need a ContactType enum and a bunch of nullable strings; the type itself handles the logic.

2. Making Illegal States Unrepresentable

Let’s apply these concepts to a real-world scenario: An E-commerce Shopping Cart. In many systems, a cart might have a status like “Empty,” “Active,” or “Paid.” In OOP, you might use an Enum and a list of items.


// The "Bad" Way (OOP)
public enum CartStatus { Empty, Active, Paid }

public class ShoppingCart {
    public CartStatus Status { get; set; }
    public List<Item> Items { get; set; }
    public DateTime? PaymentDate { get; set; }
}

The problem? A cart could have a status of Paid but a null PaymentDate. Or a status of Empty but still contain items in the list. This is an “Illegal State.”

Now, let’s look at the F# Way:


type CartItem = { Name: string; Price: decimal }

type ShoppingCart =
    | EmptyCart
    | ActiveCart of items: CartItem list
    | PaidCart of items: CartItem list * paymentDate: System.DateTime

// Example usage:
let myCart = EmptyCart
let active = ActiveCart [{ Name = "F# Book"; Price = 45.00m }]
let paid = PaidCart ([{ Name = "F# Book"; Price = 45.00m }], System.DateTime.Now)

In this F# model, it is physically impossible to have a PaidCart without a list of items and a payment date. The compiler won’t let you create one. You have just eliminated a massive category of bugs without writing a single unit test.

3. The Power of Pattern Matching

When you use Discriminated Unions, you use Pattern Matching to handle the data. Pattern matching is like a switch statement on steroids. The most important part? The F# compiler checks for exhaustiveness.


let getCartSummary cart =
    match cart with
    | EmptyCart -> 
        "Your cart is empty."
    | ActiveCart items -> 
        sprintf "You have %d items in your cart." items.Length
    | PaidCart (items, date) -> 
        sprintf "Paid for %d items on %s" items.Length (date.ToShortDateString())

// If you forget to handle "PaidCart", the F# compiler will give you a 
// warning: "Incomplete pattern matches on this expression."

If you add a new state (e.g., ShippedCart) to your Union, the compiler will immediately highlight every single match expression in your entire codebase that needs to be updated. This makes refactoring incredibly safe.

4. Step-by-Step: Modeling a User Registration Domain

Let’s walk through building a more complex domain step-by-step. We want to model a user registration system where a user can be “Unverified” or “Verified.”

Step 1: Define Primitive Types with Meaning

Avoid using string for everything. This is called “Primitive Obsession.” Instead, wrap strings in single-case unions to give them meaning.


type EmailAddress = EmailAddress of string
type VerificationCode = VerificationCode of string
type UserId = UserId of System.Guid

Step 2: Define the Domain States

A user starts as unverified and then transitions to verified.


type UserInfo = {
    Id: UserId
    Email: EmailAddress
}

type User =
    | UnverifiedUser of info: UserInfo * code: VerificationCode
    | VerifiedUser of info: UserInfo

Step 3: Define Logic as Functions

Functional programming is about transforming data. We write functions that take one state and return another.


let verifyUser (unverifiedUser: User) (enteredCode: VerificationCode) =
    match unverifiedUser with
    | VerifiedUser _ -> 
        // User is already verified, just return them
        unverifiedUser
    | UnverifiedUser (info, correctCode) ->
        if enteredCode = correctCode then
            VerifiedUser info
        else
            unverifiedUser // Or handle error logic

5. Handling Validation and Errors without Exceptions

In many languages, if validation fails, you throw an exception. But exceptions are basically “hidden GOTO statements” that make code hard to follow. In F#, we use the Result type.

The Result type is defined as:


type Result<'Success, 'Failure> =
    | Ok of 'Success
    | Error of 'Failure

Let’s create a validation function for our EmailAddress:


let createEmail (input: string) =
    if input.Contains("@") then
        Ok (EmailAddress input)
    else
        Error "Invalid email format: missing @"

// Usage
let myEmail = createEmail "hello@example.com"
match myEmail with
| Ok email -> printfn "Email is valid"
| Error err -> printfn "Error: %s" err

By returning a Result, you are forcing the caller to handle the error case. You can no longer “forget” to check if the email was valid. The code won’t compile unless you acknowledge both the Ok and Error paths.

6. Common Mistakes and How to Fix Them

Mistake 1: Using Records for Everything

Problem: Beginners often try to put every property into one large Record and use optional types (Option) for everything.

Fix: If you find yourself checking if five different fields are Some or None at the same time, you probably need a Discriminated Union. DUs capture the “states” of your domain much better than records with optional fields.

Mistake 2: Deeply Nested Pattern Matching

Problem: Writing matches inside matches (Pyramid of Doom).

Fix: Break logic into smaller functions or use “Result Computation Expressions” (often called Railway Oriented Programming) to chain operations together smoothly.

Mistake 3: Over-wrapping Primitives

Problem: Creating a new type for every single string in the system, leading to verbose code.

Fix: Only wrap primitives that have specific business rules or that could be confused with others (like OrderId vs CustomerId). If it’s just a description field that is never validated, a standard string is fine.

7. Why This Ranks as a Top Development Strategy

Beyond just making the code “prettier,” functional domain modeling has massive business benefits:

  • Reduced Maintenance Costs: Because the compiler catches logic errors, the “Total Cost of Ownership” for F# codebases is often significantly lower than OOP counterparts.
  • Living Documentation: The types are the documentation. A business analyst can almost read F# type definitions and understand the business rules.
  • Concurrency: Since data is immutable, you never have to worry about race conditions or locking. F# is naturally suited for high-performance, multi-threaded applications.

Summary and Key Takeaways

Modeling your domain in F# is about more than just syntax; it’s a shift in mindset. Instead of building defensive walls around your data, you design your data so that the walls are built-in.

  • Use Records to group related data that always exists together.
  • Use Discriminated Unions to represent mutually exclusive states or choices.
  • Make Illegal States Unrepresentable by ensuring your types can’t physically hold invalid combinations of data.
  • Prefer Results over Exceptions to make error handling explicit and visible.
  • Pattern Match to handle logic, allowing the compiler to ensure you’ve covered every possible scenario.

Frequently Asked Questions (FAQ)

1. Is F# harder to learn than C#?

The syntax is different, but many developers find it easier to maintain once they learn the basics. The hardest part is “unlearning” some OOP habits, but the reward is a massive reduction in bugs and boilerplate code.

2. Can I use these F# models with C# code?

Yes! F# compiles to standard .NET assemblies. While C# doesn’t have an exact equivalent to Discriminated Unions, it can consume F# Records and Unions quite easily, though the syntax in C# is a bit more verbose (using classes and properties).

3. Does F# perform as well as C#?

Generally, yes. F# runs on the same CoreCLR as C#. While some functional patterns (like creating many small objects) can increase GC pressure, F# provides tools for optimization (like struct records and unions) for high-performance scenarios.

4. What is “Railway Oriented Programming”?

It’s a term coined by Scott Wlaschin. It refers to the practice of chaining functions that return Result types. Imagine two tracks: a “Success” track and a “Failure” track. If any function fails, the data switches to the failure track and skips the rest of the logic, making error handling very clean.

5. Should I use Classes in F#?

F# supports classes, interfaces, and inheritance for interoperability with the .NET ecosystem. However, for internal domain logic, you should almost always prefer Records and Unions as they are more idiomatic and safer.

Now that you have a solid understanding of domain modeling in F#, it’s time to start experimenting! Try taking a small piece of your current project and rewrite it using Discriminated Unions. You’ll be amazed at how many “edge cases” simply disappear.