F# Domain Modeling: Eliminating Runtime Errors with Algebraic Data Types
You’ve just deployed a critical payment system update, and three hours later you’re debugging a null reference exception in production because someone passed an ‘approved’ order to the ‘pending payment’ workflow. The logs show the order ID, the timestamp, the stack trace—everything except why your validation logic failed to catch an impossible state transition. You trace the bug back to a helper function that accepted an Order object with a string Status property, and somewhere between the repository layer and the payment processor, the status string was “Approved” instead of “approved.”
This isn’t a rare edge case. It’s the inevitable consequence of modeling complex business domains with primitive types and hoping runtime validation catches every mistake. Your domain has rules—orders can’t be shipped before payment, subscriptions can’t be paused if they’re already cancelled, refunds can’t exceed the original transaction amount. But your type system knows nothing about these constraints. Every string could be any string. Every integer could be any integer. Your business logic lives in scattered if statements, guard clauses, and validation methods that run after invalid states have already been constructed in memory.
The promise of statically-typed languages was supposed to be catching errors at compile time. Yet we’ve collectively decided that the most critical parts of our systems—the domain rules that define what our software actually does—should be verified at the last possible moment, in production, with real customer data.
F#‘s algebraic data types offer a different approach: encode your business constraints directly into the type system, making invalid states impossible to construct in the first place. The compiler becomes your first line of defense, not your test suite.
The problem starts with how we typically represent domain concepts.
The Hidden Cost of Stringly-Typed Domain Models
Every backend engineer has encountered this pattern: a customerId is just a string, an orderStatus is an int, and an emailAddress lives in a field called string email. The code compiles, the tests pass, and somewhere in production, a customer ID gets passed where an order ID was expected. The system silently corrupts data, and you spend three days tracing the bug through twelve microservices.

This is primitive obsession—the tendency to represent domain concepts using primitive types rather than purpose-built abstractions. The immediate cost is obvious: runtime validation logic scattered across service boundaries, repository methods, and API handlers. The hidden cost runs deeper.
When the Compiler Cannot Help You
Consider a typical e-commerce order. The status might be represented as a string: "pending", "paid", "shipped", "delivered". Nothing prevents a developer from setting status to "Pending" (capitalized), "payed" (misspelled), or "cancelled" when the business logic only permits cancellation before payment. Each invalid state requires defensive code, and defensive code accumulates.
The same order contains a shipping address and a billing address—both represented as identical Address objects. Swap them accidentally, and the compiler shrugs. Pass a negative quantity or a price of zero? Perfectly valid integers. Assign a past date to a scheduledDelivery field? The DateTime type has no opinion.
These bugs share a common root: the type system knows nothing about your business domain. It sees strings, integers, and dates. It cannot distinguish a ProductSku from a TrackingNumber, even when confusing them crashes your fulfillment pipeline.
The Inheritance Trap
Object-oriented solutions often reach for inheritance hierarchies. An Order base class spawns PendingOrder, PaidOrder, ShippedOrder subclasses. This approach introduces its own problems: combinatorial explosion when states interact with order types, casting ceremonies when state transitions occur, and the fundamental limitation that an object’s type cannot change at runtime.
Worse, inheritance hierarchies model “is-a” relationships poorly when the domain demands “is-currently-in-state-of” semantics. A paid order isn’t a different kind of order—it’s the same order in a different state. Forcing this into subtype polymorphism creates friction that compounds with every new business requirement.
💡 Pro Tip: Count the
if (status == "...")checks in your codebase. Each one represents a constraint the type system could enforce but doesn’t.
The validation code, the null checks, the status string comparisons—they exist because the domain model fails to capture business rules. F#‘s algebraic data types offer an alternative: encoding constraints directly in the type system so invalid states cannot compile.
Discriminated Unions: Making Invalid States Unrepresentable
Discriminated unions (DUs) are F#‘s most powerful tool for domain modeling. Unlike C# enums that carry no data, or class hierarchies that require runtime type checking, discriminated unions combine both type identity and associated data into a single, exhaustively checkable construct. They represent the practical application of the “make illegal states unrepresentable” principle—a cornerstone of type-driven development that shifts validation from runtime checks to compile-time guarantees.
Anatomy of a Discriminated Union
Consider modeling payment methods. In C#, you’d likely create an abstract base class or interface with multiple implementations:
public abstract class PaymentMethod { }public class CreditCard : PaymentMethod { public string CardNumber { get; set; } }public class BankTransfer : PaymentMethod { public string AccountNumber { get; set; } }public class Crypto : PaymentMethod { public string WalletAddress { get; set; } }The F# equivalent is dramatically more concise:
type PaymentMethod = | CreditCard of cardNumber: string | BankTransfer of accountNumber: string | Crypto of walletAddress: stringEach case is a distinct type constructor carrying its own data. No inheritance, no null references, no forgotten implementations hiding in your codebase. The compiler knows exactly which cases exist and what data each carries, enabling static analysis that class hierarchies cannot provide.
Beyond syntactic brevity, discriminated unions offer semantic precision. Each case explicitly declares its payload, creating self-documenting code. When another developer encounters CreditCard of cardNumber: string, the intent is unambiguous. Compare this to navigating an inheritance hierarchy where discovering available payment types requires tracing through multiple files and understanding which classes extend the abstract base.
Modeling Order States with Type-Safe Transitions
Here’s where discriminated unions truly shine. When modeling an order lifecycle, invalid state transitions become compile-time errors rather than runtime surprises:
type UnvalidatedOrder = { CustomerId: string Items: string list}
type ValidatedOrder = { CustomerId: string Items: string list ValidatedAt: System.DateTime}
type PricedOrder = { CustomerId: string Items: string list TotalPrice: decimal}
type ShippedOrder = { CustomerId: string TrackingNumber: string ShippedAt: System.DateTime}
type OrderState = | Unvalidated of UnvalidatedOrder | Validated of ValidatedOrder | Priced of PricedOrder | Shipped of ShippedOrder | Cancelled of reason: stringEach state carries exactly the data relevant to that stage. A ShippedOrder has a tracking number; an UnvalidatedOrder doesn’t. You cannot accidentally access a tracking number on an unvalidated order—the type system forbids it. This design eliminates defensive null checks and “is this field populated yet?” conditionals that plague imperative codebases.
Transition functions enforce valid state progressions:
let validateOrder (order: UnvalidatedOrder) : ValidatedOrder = { CustomerId = order.CustomerId Items = order.Items ValidatedAt = System.DateTime.UtcNow }
let priceOrder (order: ValidatedOrder) (calculateTotal: string list -> decimal) : PricedOrder = { CustomerId = order.CustomerId Items = order.Items TotalPrice = calculateTotal order.Items }Notice that priceOrder accepts only a ValidatedOrder. Attempting to price an unvalidated order fails at compile time. The illegal transition from Unvalidated to Priced without validation is structurally impossible. This encoding of business rules into the type system means new team members cannot accidentally violate workflow constraints—the compiler enforces institutional knowledge.
Exhaustiveness Checking as Your Safety Net
Pattern matching against discriminated unions provides exhaustiveness checking. When you add a new order state, the compiler identifies every location that must handle it:
let describeOrder state = match state with | Unvalidated order -> sprintf "Pending validation: %d items" order.Items.Length | Validated order -> sprintf "Validated at %A" order.ValidatedAt | Priced order -> sprintf "Total: $%M" order.TotalPrice | Shipped order -> sprintf "Tracking: %s" order.TrackingNumber | Cancelled reason -> sprintf "Cancelled: %s" reasonIf you add a Refunded case to OrderState, this function triggers a compiler warning until you handle it. No runtime NotImplementedException, no silent failures in production. The compiler becomes your code reviewer, systematically verifying that every state transition and every UI display accounts for all possibilities.
This exhaustiveness guarantee scales with codebase complexity. In large systems with dozens of developers, ensuring every switch statement handles every case becomes intractable through code review alone. The compiler, however, never misses a case and never gets fatigued.
💡 Pro Tip: Enable
--warnaserror:FS0025in your build to turn incomplete pattern match warnings into errors, ensuring you never ship code with unhandled cases.
The combination of case-specific data and exhaustive matching eliminates two pervasive bug categories: accessing data that doesn’t exist for the current state, and forgetting to handle newly added states.
Discriminated unions handle what values are valid. But what about ensuring those values themselves meet business constraints? That’s where smart constructors enter the picture.
Smart Constructors and Constrained Types
Discriminated unions prevent invalid state combinations, but what about invalid primitive values? An EmailAddress represented as a raw string can hold "not-an-email" just as easily as "[email protected]". A Quantity as an int happily accepts -50. These invalid values slip through your domain boundaries and corrupt your business logic from within.
Smart constructors solve this problem by making invalid primitives impossible to create. The pattern combines private constructors with factory functions that enforce business rules at creation time. Once a value passes validation, it carries that guarantee throughout your entire application—no repeated checks, no defensive programming, no runtime surprises.
The Core Pattern
Here’s how to create an EmailAddress type that guarantees validity:
module Domain.Primitives
open System.Text.RegularExpressions
type EmailAddress = private EmailAddress of string
module EmailAddress = let private emailRegex = Regex(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", RegexOptions.Compiled)
let create (value: string) : Result<EmailAddress, string> = if System.String.IsNullOrWhiteSpace(value) then Error "Email address cannot be empty" elif not (emailRegex.IsMatch(value)) then Error $"'{value}' is not a valid email address" else Ok (EmailAddress (value.ToLowerInvariant()))
let value (EmailAddress email) = emailThe private keyword on the union case prevents direct construction outside this module. Code elsewhere in your application cannot write EmailAddress "garbage"—the compiler rejects it. The only path to an EmailAddress value runs through the create function, which enforces your validation rules.
This encapsulation also lets you normalize values during construction. Notice how the email address is converted to lowercase—callers receive a canonical form without needing to remember this transformation themselves.
Building a Library of Domain Primitives
Apply this pattern consistently across your domain. Each primitive type encapsulates its specific business rules:
type OrderId = private OrderId of System.Guid
module OrderId = let create (value: System.Guid) : Result<OrderId, string> = if value = System.Guid.Empty then Error "OrderId cannot be empty" else Ok (OrderId value)
let newId () = OrderId (System.Guid.NewGuid()) let value (OrderId id) = id
type Money = private Money of decimal
module Money = let create (amount: decimal) : Result<Money, string> = if amount < 0m then Error "Money cannot be negative" elif System.Decimal.Round(amount, 2) <> amount then Error "Money must have at most 2 decimal places" else Ok (Money amount)
let zero = Money 0m let value (Money amount) = amount
let add (Money a) (Money b) = Money (a + b) let subtract (Money a) (Money b) : Result<Money, string> = create (a - b)Notice how Money.subtract returns a Result because subtraction might produce a negative value. The type system forces callers to handle this possibility—you cannot accidentally overdraw an account without explicitly acknowledging the potential for failure.
The Money.add function, by contrast, returns Money directly. Adding two non-negative amounts always produces a non-negative result, so no Result wrapper is needed. This distinction communicates operation semantics through types rather than documentation.
Eliminating Defensive Programming
Once validated values exist, they stay valid. Functions accepting EmailAddress or Money parameters never need null checks, format validation, or range verification:
let sendConfirmation (orderId: OrderId) (email: EmailAddress) (total: Money) = // No validation needed—these values are guaranteed valid let message = $"Order {OrderId.value orderId} confirmed: ${Money.value total}" EmailService.send (EmailAddress.value email) messageCompare this to defensive code littered with if String.IsNullOrEmpty checks and validation calls at every function boundary. Smart constructors move that complexity to a single location—the construction site—leaving your domain logic clean and focused on business rules rather than data validation.
💡 Pro Tip: Create smart constructors at your system boundaries—API endpoints, message handlers, database reads—then pass validated types throughout your domain. Validation happens once, at the edges.
Composing Constrained Types
Combine primitives into richer domain structures:
type Customer = { Id: CustomerId Email: EmailAddress Name: NonEmptyString CreditLimit: Money}Every field carries its constraints. A Customer record with an invalid email or negative credit limit cannot exist in your running application. The compiler becomes your first line of defense, catching constraint violations before tests even run.
This compositional approach scales naturally. As your domain grows, you build increasingly sophisticated types from validated primitives, with guarantees compounding at each level. A ValidatedOrder containing Customer, Money, and OrderId fields inherits all their constraints automatically.
With individual values protected, you’re ready to model entire workflows. State machines built from discriminated unions take these guarantees further—ensuring not just that values are valid, but that transitions between states follow your business rules exactly.
Modeling Complex Workflows with State Machines
Business workflows present a deceptively difficult modeling challenge. An order moves through multiple states—placed, paid, shipped, delivered—and each state has different data requirements and valid transitions. Traditional object-oriented approaches stuff all possible fields into a single class, relying on runtime checks and nullable fields to manage state. F#‘s discriminated unions let you encode the entire state machine into the type system, making illegal state transitions impossible to compile.

The Order Fulfillment State Machine
Consider an e-commerce order workflow. An order starts as placed, then becomes paid (with payment details), then shipped (with tracking information), and finally delivered (with delivery confirmation). Each state carries fundamentally different data.
type OrderId = OrderId of stringtype PaymentId = PaymentId of stringtype TrackingNumber = TrackingNumber of string
type PlacedOrder = { OrderId: OrderId CustomerId: string Items: OrderItem list PlacedAt: DateTimeOffset}
type PaidOrder = { OrderId: OrderId CustomerId: string Items: OrderItem list PlacedAt: DateTimeOffset PaymentId: PaymentId PaidAt: DateTimeOffset}
type ShippedOrder = { OrderId: OrderId CustomerId: string Items: OrderItem list TrackingNumber: TrackingNumber ShippedAt: DateTimeOffset Carrier: string}
type DeliveredOrder = { OrderId: OrderId TrackingNumber: TrackingNumber DeliveredAt: DateTimeOffset SignedBy: string option}
type Order = | Placed of PlacedOrder | Paid of PaidOrder | Shipped of ShippedOrder | Delivered of DeliveredOrderEach state is a distinct type with precisely the fields relevant to that stage. A PlacedOrder has no payment information because payment hasn’t occurred. A ShippedOrder doesn’t carry the full item list because that data lives in the fulfillment system. The compiler enforces these boundaries.
Compare this to a typical OOP approach where a single Order class contains nullable PaymentId?, TrackingNumber?, and DeliveredAt? fields. Every method must check which fields are populated before proceeding, and nothing prevents code from accessing TrackingNumber on an unpaid order except developer discipline and runtime exceptions.
State Transition Functions
State transitions become functions with explicit input and output types. You cannot accidentally ship an unpaid order because the type system rejects it.
let processPayment (payment: PaymentInfo) (order: PlacedOrder) : Result<PaidOrder, PaymentError> = match PaymentGateway.charge payment order.Items with | Ok paymentId -> Ok { OrderId = order.OrderId CustomerId = order.CustomerId Items = order.Items PlacedAt = order.PlacedAt PaymentId = paymentId PaidAt = DateTimeOffset.UtcNow } | Error e -> Error e
let shipOrder (carrier: string) (tracking: TrackingNumber) (order: PaidOrder) : ShippedOrder = { OrderId = order.OrderId CustomerId = order.CustomerId Items = order.Items TrackingNumber = tracking ShippedAt = DateTimeOffset.UtcNow Carrier = carrier }
let confirmDelivery (signedBy: string option) (order: ShippedOrder) : DeliveredOrder = { OrderId = order.OrderId TrackingNumber = order.TrackingNumber DeliveredAt = DateTimeOffset.UtcNow SignedBy = signedBy }The function shipOrder takes a PaidOrder and returns a ShippedOrder. Calling shipOrder with a PlacedOrder produces a compile error. The workflow rules are encoded in the function signatures, not buried in conditional logic.
Notice how processPayment returns a Result type while shipOrder returns a plain ShippedOrder. This distinction communicates intent: payment can fail for external reasons (declined card, network timeout), but shipping an already-paid order is an internal operation that succeeds deterministically given valid inputs. The type signatures document these operational characteristics.
Handling Workflow Commands
When processing commands from an API or message queue, pattern matching ensures every state handles (or explicitly rejects) each command.
type OrderCommand = | ProcessPayment of PaymentInfo | Ship of carrier: string * TrackingNumber | ConfirmDelivery of signedBy: string option
let handleCommand (cmd: OrderCommand) (order: Order) : Result<Order, WorkflowError> = match cmd, order with | ProcessPayment payment, Placed placedOrder -> processPayment payment placedOrder |> Result.map Paid |> Result.mapError WorkflowError.Payment
| Ship (carrier, tracking), Paid paidOrder -> shipOrder carrier tracking paidOrder |> Shipped |> Ok
| ConfirmDelivery signedBy, Shipped shippedOrder -> confirmDelivery signedBy shippedOrder |> Delivered |> Ok
| _, Delivered _ -> Error WorkflowError.OrderAlreadyComplete
| _ -> Error WorkflowError.InvalidStateTransition💡 Pro Tip: The final catch-all pattern handles invalid transitions uniformly. If you add a new state, the compiler warns you about unhandled cases in every command handler, preventing silent bugs from reaching production.
This approach scales to complex workflows with branching paths, cancellations, and rollbacks. Each branch gets its own type, and transitions between branches require explicit conversion functions. The state machine lives in the type definitions, visible to every developer reading the code.
Why This Matters in Production
State machine modeling with discriminated unions eliminates an entire category of bugs: invalid state transitions, missing null checks on state-dependent fields, and forgotten edge cases. The compiler catches these errors during development, not during a 2 AM production incident.
Beyond correctness, this pattern improves code comprehension. New team members can read the type definitions and immediately understand the workflow without tracing through conditional branches scattered across multiple files. The types serve as executable documentation that cannot drift from the implementation.
The pattern also simplifies testing. Each transition function is pure and isolated—you test processPayment without setting up shipping infrastructure, and test shipOrder without mocking payment gateways. State-specific behavior lives in state-specific functions.
Of course, state transitions can fail for business reasons—payment declined, inventory unavailable, address invalid. The next section explores how Result types and railway-oriented programming handle these failures elegantly, composing fallible operations into clean pipelines.
Result Types and Railway-Oriented Error Handling
Exceptions hide failure paths in your code. When a method signature shows User -> Order, you have no compile-time indication that this operation can fail, let alone how it fails. The caller discovers failure modes through runtime explosions or by spelunking through implementation details. F#‘s Result type makes failure an explicit part of your function’s contract, transforming error handling from an afterthought into a first-class design concern.
The Result Type Foundation
The Result<'Success, 'Error> type is a discriminated union with two cases:
// Built into F# as:// type Result<'T, 'TError> = Ok of 'T | Error of 'TError
type OrderError = | ProductNotFound of ProductId | InsufficientInventory of ProductId * requested: int * available: int | CustomerCreditExceeded of CustomerId * limit: decimal * attempted: decimal | InvalidQuantity of int
let createOrderLine (inventory: Map<ProductId, int>) (productId: ProductId) (quantity: int) : Result<OrderLine, OrderError> = if quantity <= 0 then Error (InvalidQuantity quantity) else match Map.tryFind productId inventory with | None -> Error (ProductNotFound productId) | Some available when available < quantity -> Error (InsufficientInventory (productId, quantity, available)) | Some _ -> Ok { ProductId = productId; Quantity = quantity }Every caller knows this function can fail and exactly what failures to handle. The compiler enforces exhaustive pattern matching—add a new error case and every call site that doesn’t handle it becomes a compile error. This stands in stark contrast to exception-based approaches where new failure modes silently propagate up the call stack until they crash your application in production.
The discriminated union approach also eliminates the need for error code constants, exception hierarchies, or stringly-typed error messages. Each error case carries precisely the data needed to understand and potentially recover from the failure. InsufficientInventory includes the product, requested amount, and available stock—everything the UI needs to display a meaningful message or suggest alternatives.
Composing Fallible Operations
Chaining operations that return Result without nested match expressions requires the bind function (also written as >>= in other languages). F# provides this through computation expressions:
let validateAndCreateOrder (customerId: CustomerId) (items: OrderRequest list) = result { let! customer = findCustomer customerId |> Result.mapError CustomerError let! validatedItems = validateItems items |> Result.mapError ValidationError let! creditCheck = checkCredit customer validatedItems |> Result.mapError CreditError return createOrder customer validatedItems }Each let! unwraps a successful Result and continues, or short-circuits on the first error. This “railway-oriented” pattern treats your happy path as one track and errors as another—once you derail to the error track, subsequent operations are bypassed. The computation expression desugars to nested Result.bind calls, but reads like synchronous, imperative code.
The Result.mapError calls deserve attention. They transform specific error types into a unified order workflow error, enabling composition across module boundaries. Without this transformation, you’d need a single monolithic error type shared across your entire codebase—a maintenance nightmare that couples unrelated modules.
Accumulating Multiple Errors
Sometimes short-circuiting on the first error provides poor user experience. Validation scenarios benefit from collecting all errors rather than forcing users through a frustrating cycle of fix-submit-discover-repeat:
type ValidationError = { Field: string; Message: string }
module Validation = let apply (fResult: Result<'a -> 'b, 'err list>) (xResult: Result<'a, 'err list>) = match fResult, xResult with | Ok f, Ok x -> Ok (f x) | Error errs, Ok _ -> Error errs | Ok _, Error errs -> Error errs | Error errs1, Error errs2 -> Error (errs1 @ errs2)
let validateOrder (request: OrderRequest) = let validateCustomerId id = if String.IsNullOrWhiteSpace id then Error [{ Field = "customerId"; Message = "Required" }] else Ok (CustomerId id)
let validateQuantity qty = if qty <= 0 then Error [{ Field = "quantity"; Message = "Must be positive" }] else Ok qty
let validateProductCode code = if Regex.IsMatch(code, @"^[A-Z]{3}-\d{4}$") then Ok (ProductCode code) else Error [{ Field = "productCode"; Message = "Invalid format (expected XXX-0000)" }]
let createValidatedOrder custId prodCode qty = { CustomerId = custId; ProductCode = prodCode; Quantity = qty }
Ok createValidatedOrder |> Validation.apply (validateCustomerId request.CustomerId) |> Validation.apply (validateProductCode request.ProductCode) |> Validation.apply (validateQuantity request.Quantity)This applicative style runs all validations regardless of individual failures, accumulating errors into a list. Your API returns ["customerId: Required", "quantity: Must be positive"] rather than forcing users to fix one error, resubmit, discover the next, and repeat. The pattern scales elegantly—adding a new field requires only adding another Validation.apply call.
The key insight is recognizing when to use monadic composition (bind/let!) versus applicative composition (apply). Use bind when later validations depend on earlier results—you can’t validate order line items before confirming the customer exists. Use apply when validations are independent—customer ID format and quantity constraints have no interdependency.
💡 Pro Tip: Use
Resultfor expected, recoverable failures (validation errors, business rule violations). Reserve exceptions for truly exceptional conditions like database connection failures or corrupted data. This distinction keeps your error handling predictable and your exception logs meaningful.
The combination of discriminated unions for error types and Result for control flow creates self-documenting code. New team members read function signatures and immediately understand both success and failure scenarios. Code reviews become more effective when failure modes are visible in the type system rather than buried in implementation details or scattered across catch blocks.
With domain models and error handling established, the next challenge is integrating these F# patterns with existing .NET infrastructure—Entity Framework, ASP.NET Core, and serialization frameworks that expect C#-style objects.
Integrating F# Domain Models with .NET Infrastructure
Pure domain models provide compile-time guarantees, but production systems require persistence, serialization, and API exposure. The challenge lies in preserving type safety at the boundaries while integrating with infrastructure that expects mutable, nullable types. This section explores practical patterns for bridging F# domain models with common .NET infrastructure components, ensuring type safety flows from core domain logic to external interfaces.
JSON Serialization with System.Text.Json
Discriminated unions require custom serialization because JSON has no native representation for sum types. The recommended approach uses a tagged union format with a discriminator field that preserves case information across serialization boundaries.
open System.Text.Jsonopen System.Text.Json.Serialization
[<JsonFSharpConverter(JsonUnionEncoding.AdjacentTag)>]type PaymentMethod = | CreditCard of CardNumber: string * Expiry: string | BankTransfer of AccountNumber: string * RoutingNumber: string | PayPal of Email: string
let options = JsonSerializerOptions()options.Converters.Add(JsonFSharpConverter())
let payment = CreditCard("4111111111111111", "12/27")let json = JsonSerializer.Serialize(payment, options)// {"Case":"CreditCard","Fields":{"CardNumber":"4111111111111111","Expiry":"12/27"}}The FSharp.SystemTextJson library handles discriminated unions, option types, and records automatically. For APIs consumed by non-F# clients, consider flattening the structure with custom converters or use DTOs at the boundary. The library supports multiple encoding strategies including InternalTag for embedding the discriminator within the object and UnwrapSingleFieldCases for simpler single-field unions. Choose the encoding that best matches your API consumers’ expectations while maintaining round-trip fidelity.
Database Mapping with Dapper
Entity Framework Core struggles with discriminated unions due to its reliance on inheritance hierarchies and convention-based mapping. Dapper offers more flexibility through manual mapping, giving you explicit control over how F# types translate to relational schemas.
open Dapperopen System.Data.SqlClient
type OrderStatusDto = { OrderId: Guid Status: string ShippedDate: DateTime option CancelledReason: string option}
let toDomain (dto: OrderStatusDto): OrderStatus = match dto.Status with | "Pending" -> Pending | "Shipped" -> Shipped dto.ShippedDate.Value | "Cancelled" -> Cancelled dto.CancelledReason.Value | "Delivered" -> Delivered | status -> failwith $"Unknown status: {status}"
let fromDomain (orderId: Guid) (status: OrderStatus): OrderStatusDto = match status with | Pending -> { OrderId = orderId; Status = "Pending"; ShippedDate = None; CancelledReason = None } | Shipped date -> { OrderId = orderId; Status = "Shipped"; ShippedDate = Some date; CancelledReason = None } | Cancelled reason -> { OrderId = orderId; Status = "Cancelled"; ShippedDate = None; CancelledReason = Some reason } | Delivered -> { OrderId = orderId; Status = "Delivered"; ShippedDate = None; CancelledReason = None }
let getOrderStatus (conn: SqlConnection) (orderId: Guid) = conn.QuerySingleAsync<OrderStatusDto>( "SELECT OrderId, Status, ShippedDate, CancelledReason FROM Orders WHERE OrderId = @OrderId", {| OrderId = orderId |}) |> Async.AwaitTask |> Async.map toDomainThe DTO layer acts as an anti-corruption boundary, ensuring invalid database states fail fast during mapping rather than propagating through the domain. This explicit mapping also simplifies schema migrations—you can evolve the database schema independently and adjust only the mapping functions.
💡 Pro Tip: Store discriminated union cases as string columns rather than integer codes. This improves debugging, supports schema evolution, and makes database queries self-documenting. When adding new cases, existing data remains valid without migration scripts.
For teams committed to Entity Framework Core, consider using single-table inheritance with a discriminator column for simpler unions, or table-per-hierarchy mapping for complex cases. However, the mapping overhead often outweighs EF Core’s benefits when working extensively with discriminated unions.
ASP.NET Core API Integration
Expose domain types through minimal APIs with explicit response mapping. Keep validation at the boundary using smart constructors from earlier sections, ensuring invalid requests never reach domain logic.
open Microsoft.AspNetCore.Builderopen Microsoft.AspNetCore.Http
let mapOrderResponse (order: ValidatedOrder) = {| OrderId = order.Id |> OrderId.value Customer = order.Customer |> CustomerEmail.value Status = order.Status |> function | Pending -> "pending" | Shipped d -> $"shipped:{d:yyyy-MM-dd}" | Cancelled r -> $"cancelled:{r}" | Delivered -> "delivered" Total = order.Total |> Money.value |}
let app = WebApplication.Create()
app.MapGet("/orders/{id:guid}", fun (id: Guid) (repo: IOrderRepository) -> task { match! repo.GetById(OrderId.create id) with | Some order -> return Results.Ok(mapOrderResponse order) | None -> return Results.NotFound() }) |> ignoreThis pattern maintains a clean separation: domain types remain pure and infrastructure concerns stay at the edges. Input validation uses Result types, and response mapping handles serialization without polluting domain logic. For POST endpoints, parse request bodies into Result types and use pattern matching to return appropriate HTTP status codes for validation failures.
The infrastructure layer inevitably introduces some ceremony, but the investment pays dividends through explicit failure modes and predictable data transformations. Teams adopting this approach report fewer production incidents from serialization mismatches and database mapping errors. The explicit boundaries also simplify testing—domain logic tests remain pure and fast, while integration tests focus on the infrastructure adapters.
With infrastructure concerns addressed, the final consideration is incremental adoption—introducing F# domain modeling into existing C# codebases without disrupting ongoing development.
Adopting F# Domain Modeling in Existing Codebases
Rewriting a production system from scratch rarely succeeds. The path to type-safe domain modeling runs through incremental adoption, where F# components gradually replace the most error-prone parts of your codebase while coexisting with existing C# infrastructure.
Start with a Bounded Context
Domain-Driven Design provides the perfect adoption strategy: identify a single bounded context where invalid states cause the most pain. Payment processing, order fulfillment, and subscription management typically exhibit high bug rates from state mismanagement—these make excellent candidates.
Choose a bounded context that:
- Has well-defined boundaries with clear inputs and outputs
- Suffers from frequent bugs related to invalid state combinations
- Operates somewhat independently from the rest of the system
- Contains business logic complex enough to benefit from algebraic types
Your first F# module becomes a black box to the rest of the system. C# code calls into it through a thin interface layer, receives strongly-typed results, and never needs to understand the internal representation.
F# and C# Interoperability Patterns
F# compiles to the same IL as C#, enabling seamless interop at the assembly level. The friction comes from exposing F#-specific constructs like discriminated unions and option types to C# consumers.
Establish clear boundaries by creating explicit API surfaces. Your F# domain module exposes functions that accept and return types C# handles naturally—records become classes with public properties, and discriminated unions transform into result objects with success/failure indicators. Inside the boundary, you maintain full type safety with pattern matching and exhaustiveness checking.
For data transfer, define dedicated DTO types at the boundary. These DTOs map to and from your rich domain types, keeping the internal model pure while providing a familiar interface to C# callers.
💡 Pro Tip: Place your interop layer in a separate F# file suffixed with
.Interop.fs. This keeps boundary concerns isolated from core domain logic and makes the integration surface explicit during code review.
Measuring Impact
Track three metrics before and after adoption: bug rates in the migrated context, time spent debugging state-related issues, and developer velocity on feature work. Teams consistently report 40-60% reductions in production incidents related to invalid state after migrating critical bounded contexts to F#.
Code review cycles accelerate because the compiler catches entire categories of errors that previously required careful human inspection. New team members ramp up faster when the type system documents valid state transitions.
With a successful bounded context migration complete, you have both the evidence and the experience to expand F# adoption systematically across your architecture.
Key Takeaways
- Start modeling your next feature’s core domain types as discriminated unions before writing any business logic—let the compiler catch invalid state transitions
- Replace primitive string and int parameters with constrained wrapper types that validate at construction, eliminating scattered validation code
- Use Result types instead of exceptions for expected failure cases to make error handling explicit and composable in your API boundaries
- Introduce F# domain models in a single bounded context first, using the interop layer to integrate with existing C# infrastructure