F# Record of Functions: A Superior Alternative to Abstract Member Interfaces
Why I Prefer Record of Functions Over Abstract Member Interfaces in F#
Throughout my years of working with F#, I've consistently preferred using record of functions instead of abstract member interfaces. This approach is not only more idiomatic for functional programming but also results in cleaner, more maintainable code.
The Traditional Approach: Abstract Member Interfaces
Here's how you might typically define an abstract member interface in F#:
type IUserRepository =
abstract member GetById: int -> User option
abstract member Save: User -> unit
abstract member Delete: int -> bool
abstract member FindByEmail: string -> User option
type SqlUserRepository() =
interface IUserRepository with
member _.GetById id =
// Implementation
None
member _.Save user =
// Implementation
()
member _.Delete id =
// Implementation
false
member _.FindByEmail email =
// Implementation
None
The Functional Alternative: Record of Functions
Now, here's the same concept using a record of functions:
type UserRepository = {
GetById: int -> User option
Save: User -> unit
Delete: int -> bool
FindByEmail: string -> User option
}
let createSqlUserRepository connectionString = {
GetById = fun id ->
// Implementation
None
Save = fun user ->
// Implementation
()
Delete = fun id ->
// Implementation
false
FindByEmail = fun email ->
// Implementation
None
}
Why Record of Functions Is Superior
1. More Idiomatic Functional Code
Record of functions aligns perfectly with functional programming principles. They treat behaviors as first-class values that can be composed, transformed, and passed around freely. This approach embraces the functional paradigm rather than fighting against it with OOP constructs.
2. Cleaner and More Readable
The record syntax is significantly cleaner:
No need for
abstract member
declarationsNo need for
interface
implementationsNo need for the awkward
_.
syntaxFunction signatures are immediately clear and concise
3. Easier Composition and Testing
Records make it trivial to create test doubles and compose functionality:
// Easy test double creation
let testRepository = {
GetById = fun _ -> Some { Id = 1; Name = "Test User"; Email = "test@example.com" }
Save = fun _ -> ()
Delete = fun _ -> true
FindByEmail = fun _ -> None
}
// Partial application and composition
let withLogging (repo: UserRepository) = {
repo with
Save = fun user ->
printfn "Saving user: %A" user
repo.Save user
Delete = fun id ->
printfn "Deleting user: %d" id
let result = repo.Delete id
printfn "Delete result: %b" result
result
}
// Easy dependency injection
let createUserService (repo: UserRepository) =
// Service implementation using the repository
()
4. Better Type Inference
F# excels at type inference, and record of functions works seamlessly with this feature. You rarely need to specify types explicitly, making the code more concise while maintaining type safety.
5. No Inheritance Complexity
With records, there's no inheritance hierarchy to worry about. You can easily create variations by copying and updating specific fields, leading to more predictable and maintainable code.
When to Use Abstract Member Interfaces
I only resort to abstract member interfaces when absolutely necessary for interop with C# or other .NET languages. For example:
// Only when you need to implement a C# interface
type FSharpImplementation() =
interface ICSharpInterface with
member _.RequiredMethod() =
// Implementation for C# consumption
()
Even then, I often wrap these implementations behind record of functions for use within my F# code.
Dependency Injection with Record of Functions in Web Frameworks
Record of functions integrates seamlessly with dependency injection in various F# web frameworks. This approach works beautifully whether you're building ASP.NET Core WebAPIs, Fable applications, or Giraffe services.
ASP.NET Core WebAPI
Note: I've never actually used traditional ASP.NET Core WebAPI controllers in F#. I've always preferred Fable.Remoting for APIs (which provides type-safe RPC-style communication) or Giraffe handlers when I need HTTP-level functionality for things like file upload/download. However, if you do use ASP.NET Core WebAPI, here's how record of functions works with it:
In ASP.NET Core, you can register your record-based services directly with the DI container:
// Define your services as records
type IEmailService = {
SendEmail: string -> string -> string -> Async<Result<unit, string>>
SendBulkEmails: EmailMessage list -> Async<Result<int, string>>
}
type IUserService = {
CreateUser: CreateUserRequest -> Async<Result<User, string>>
GetUser: UserId -> Async<Result<User, string>>
UpdateUser: UserId -> UpdateUserRequest -> Async<Result<User, string>>
}
// Register in Startup.fs or Program.fs
let configureServices (services: IServiceCollection) =
// Register repositories
services.AddSingleton<UserRepository>(fun sp ->
let connectionString = sp.GetRequiredService<IConfiguration>().GetConnectionString("Default")
createSqlUserRepository connectionString
) |> ignore
// Register services with dependencies
services.AddScoped<IUserService>(fun sp ->
let repo = sp.GetRequiredService<UserRepository>()
let emailService = sp.GetRequiredService<IEmailService>()
createUserService repo emailService
) |> ignore
services.AddScoped<IEmailService>(fun sp ->
let config = sp.GetRequiredService<EmailConfig>()
createEmailService config
) |> ignore
// Use in controllers
type UserController(userService: IUserService) =
inherit ControllerBase()
[<HttpPost>]
member _.CreateUser(request: CreateUserRequest) = async {
let! result = userService.CreateUser request
return match result with
| Ok user -> OkObjectResult(user) :> IActionResult
| Error msg -> BadRequestObjectResult(msg) :> IActionResult
}
Giraffe Handlers
Giraffe's functional approach pairs perfectly with record of functions:
// Define your dependencies as records
type AppDependencies = {
UserRepo: UserRepository
EmailService: EmailService
Logger: Logger
Config: AppConfiguration
}
// Create a helper for dependency injection
let createAppDependencies (services: IServiceProvider) = {
UserRepo = services.GetRequiredService<UserRepository>()
EmailService = services.GetRequiredService<EmailService>()
Logger = services.GetRequiredService<Logger>()
Config = services.GetRequiredService<AppConfiguration>()
}
// Use in Giraffe handlers
let getUserHandler (userId: int) =
fun (next: HttpFunc) (ctx: HttpContext) ->
let deps = ctx.RequestServices |> createAppDependencies
task {
let! user = deps.UserRepo.GetById (UserId userId)
return! match user with
| Some u -> json u next ctx
| None -> RequestErrors.notFound (text "User not found") next ctx
}
// Or use a more functional approach with partial application
let createUserHandler (deps: AppDependencies) =
fun (next: HttpFunc) (ctx: HttpContext) ->
task {
let! request = ctx.BindJsonAsync<CreateUserRequest>()
let! result = deps.UserRepo.Save {
Id = UserId 0
Name = request.Name
Email = Email request.Email
}
return! json result next ctx
}
// Configure routes with dependencies
let webApp =
let deps = createAppDependencies serviceProvider
choose [
GET >=> route "/users" >=> getUsersHandler deps
POST >=> route "/users" >=> createUserHandler deps
GET >=> routef "/users/%i" getUserHandler
]
Fable.Remoting Applications
In Fable.Remoting applications, record of functions works beautifully for both APIs and repositories:
// Shared domain types
type Product = {
Id: int
Name: string
Price: decimal
InStock: bool
}
// Define your repository as a record of functions
type ProductRepository = {
GetAll: unit -> Async<Product list>
GetById: int -> Async<Product option>
Create: Product -> Async<Result<Product, string>>
Update: int -> Product -> Async<Result<Product, string>>
Delete: int -> Async<Result<unit, string>>
SearchByName: string -> Async<Product list>
}
// Define your API as a record of functions (shared with client)
type IProductApi = {
GetProducts: unit -> Async<Product list>
GetProduct: int -> Async<Product option>
CreateProduct: Product -> Async<Result<Product, string>>
UpdateProduct: int * Product -> Async<Result<Product, string>>
DeleteProduct: int -> Async<Result<unit, string>>
SearchProducts: string -> Async<Product list>
}
// Create the API implementation using repository
let createProductApi (repository: ProductRepository) : IProductApi = {
GetProducts = repository.GetAll
GetProduct = repository.GetById
CreateProduct = repository.Create
UpdateProduct = fun (id, product) -> repository.Update id product
DeleteProduct = repository.Delete
SearchProducts = repository.SearchByName
}
// Bundle multiple repositories as a record
type IRepositories = {
Products: ProductRepository
Orders: OrderRepository
Customers: CustomerRepository
}
// Wire up with Giraffe and dependency injection
let webApp =
choose [
Remoting.createApi ()
|> Remoting.withRouteBuilder (sprintf "/api/%s/%s")
|> Remoting.fromContext (fun (ctx: HttpContext) ->
let repositories = ctx.RequestServices.GetRequiredService<IRepositories>()
createProductApi repositories.Products)
|> Remoting.buildHttpHandler
// Other routes
route "/" >=> text "API Running"
]
// Configure services
let configureServices (services: IServiceCollection) =
// Register repositories as a single record
services.AddSingleton<IRepositories>(fun sp ->
let connString = sp.GetRequiredService<IConfiguration>().GetConnectionString("Default")
{
Products = createProductRepository connString
Orders = createOrderRepository connString
Customers = createCustomerRepository connString
}) |> ignore
// Client-side usage (Fable)
let api =
Remoting.createApi()
|> Remoting.withRouteBuilder (sprintf "/api/%s/%s")
|> Remoting.buildProxy<IProductApi>
// Use the API from client
async {
let! products = api.GetProducts()
let! result = api.CreateProduct { Id = 0; Name = "New Product"; Price = 99.99m; InStock = true }
match result with
| Ok product -> printfn "Created: %A" product
| Error msg -> printfn "Error: %s" msg
}
Testing with Dependency Injection
Records make it trivial to create test doubles. Here's how I use them with Expecto:
open Expecto
// Create test doubles for repositories
let createInMemoryUserRepository () =
let mutable users = Map.empty<UserId, User>
let mutable nextId = 1
{
GetById = fun id -> async {
return users |> Map.tryFind id
}
Save = fun user -> async {
let user =
if user.Id = UserId 0 then
let newUser = { user with Id = UserId nextId }
nextId <- nextId + 1
newUser
else
user
users <- users |> Map.add user.Id user
}
Delete = fun id -> async {
let exists = users |> Map.containsKey id
users <- users |> Map.remove id
return exists
}
FindByEmail = fun email -> async {
return users
|> Map.values
|> Seq.tryFind (fun u -> u.Email = email)
}
GetAll = fun () -> async {
return users |> Map.values |> List.ofSeq
}
}
// Expecto tests
let userServiceTests =
testList "UserService" [
testAsync "should create user successfully" {
// Arrange
let repo = createInMemoryUserRepository()
let service = createUserService repo
// Act
let! result = service.CreateUser "John Doe" "john@example.com"
// Assert
Expect.isOk result "User creation should succeed"
let user = result |> Result.defaultWith (fun _ -> failtest "Should have user")
Expect.equal user.Name "John Doe" "Name should match"
Expect.equal user.Email (Email "john@example.com") "Email should match"
}
testAsync "should not create duplicate users with same email" {
// Arrange
let repo = createInMemoryUserRepository()
let service = createUserService repo
// Act - Create first user
let! firstResult = service.CreateUser "John Doe" "john@example.com"
let! secondResult = service.CreateUser "Jane Doe" "john@example.com"
// Assert
Expect.isOk firstResult "First user should be created"
Expect.isError secondResult "Second user with same email should fail"
}
testAsync "should update user name" {
// Arrange
let repo = createInMemoryUserRepository()
let service = createUserService repo
let! createResult = service.CreateUser "John Doe" "john@example.com"
let userId =
createResult
|> Result.map (fun u -> u.Id)
|> Result.defaultWith (fun _ -> failtest "Should have created user")
// Act
let! updateResult = service.UpdateUser userId "John Smith"
// Assert
Expect.isOk updateResult "Update should succeed"
let updatedUser = updateResult |> Result.defaultWith (fun _ -> failtest "Should have user")
Expect.equal updatedUser.Name "John Smith" "Name should be updated"
}
testProperty "any valid email and name creates a user" <| fun (name: string) (email: string) ->
if String.IsNullOrWhiteSpace(name) || String.IsNullOrWhiteSpace(email) then
() // Skip invalid inputs
else
let repo = createInMemoryUserRepository()
let service = createUserService repo
let result = service.CreateUser name email |> Async.RunSynchronously
Expect.isOk result "Valid inputs should create user"
]
// Run tests
[<EntryPoint>]
let main argv =
runTestsWithCLIArgs [] argv userServiceTests
This approach gives you all the benefits of dependency injection while maintaining functional purity and avoiding the complexity of abstract member interfaces.
Practical Example: Building a Complete Service Layer
Here's a more complete example showing how record of functions creates a clean, testable service layer:
// Domain types
type UserId = UserId of int
type Email = Email of string
type User = {
Id: UserId
Name: string
Email: Email
}
// Repository as a record of functions
type UserRepository = {
GetById: UserId -> Async<User option>
Save: User -> Async<unit>
Delete: UserId -> Async<bool>
FindByEmail: Email -> Async<User option>
GetAll: unit -> Async<User list>
}
// Service layer using the repository
type UserService = {
CreateUser: string -> string -> Async<Result<User, string>>
UpdateUser: UserId -> string -> Async<Result<User, string>>
DeleteUser: UserId -> Async<Result<unit, string>>
GetUserByEmail: string -> Async<Result<User, string>>
}
let createUserService (repo: UserRepository) = {
CreateUser = fun name email -> async {
let emailValue = Email email
let! existing = repo.FindByEmail emailValue
match existing with
| Some _ -> return Error "User with this email already exists"
| None ->
let user = {
Id = UserId 0 // Will be assigned by database
Name = name
Email = emailValue
}
do! repo.Save user
return Ok user
}
UpdateUser = fun userId name -> async {
let! user = repo.GetById userId
match user with
| None -> return Error "User not found"
| Some u ->
let updated = { u with Name = name }
do! repo.Save updated
return Ok updated
}
DeleteUser = fun userId -> async {
let! success = repo.Delete userId
if success then
return Ok ()
else
return Error "Failed to delete user"
}
GetUserByEmail = fun email -> async {
let! user = repo.FindByEmail (Email email)
match user with
| Some u -> return Ok u
| None -> return Error "User not found"
}
}
Conclusion
Record of functions represents the most natural way to define contracts and dependencies in F#. They're cleaner, more composable, and more aligned with functional programming principles than abstract member interfaces. By embracing this approach, you'll write F# code that is more maintainable, testable, and genuinely functional.
The only time I deviate from this pattern is when forced to by interop requirements with other .NET languages. Even then, I minimize the surface area of such code and wrap it in record of functions.
This approach has served me well across numerous F# projects, from small utilities to large-scale applications. It's a pattern that grows with your codebase and makes functional programming in F# a joy rather than a struggle against object-oriented constructs.