Functional Architecture
Design production systems with functional principles: pure core/imperative shell, concurrency, .NET interop, performance, and testing.
Functional Architecture
Master functional architecture with free flashcards and spaced repetition practice. This lesson covers designing systems with pure functions, composable modules, and immutable stateโessential concepts for building maintainable F# applications. You'll learn to structure codebases that are testable, scalable, and resilient to change.
Welcome ๐๏ธ
Welcome to Functional Architecture! Architecture in functional programming differs fundamentally from object-oriented design. Instead of organizing code around mutable objects and classes, functional architecture emphasizes composability, immutability, and separation of pure logic from effects.
In this lesson, you'll discover how to structure F# applications using functional principles. We'll explore the onion architecture pattern adapted for functional programming, learn how to separate business logic from infrastructure concerns, and understand techniques for managing side effects at the boundaries of your system.
Core Concepts ๐ก
The Functional Core, Imperative Shell Pattern
The most fundamental principle of functional architecture is keeping pure functions at the core while pushing side effects to the edges. This pattern creates a clear boundary between:
Functional Core ๐งฎ
- Pure functions with no side effects
- Business logic and domain rules
- Easily testable without mocks
- Deterministic and cacheable
Imperative Shell ๐
- I/O operations (database, file system, network)
- Framework integration
- Thin layer that coordinates the core
- Handles all "dirty" work
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ IMPERATIVE SHELL (Thin) โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ โ โ โ โ โ FUNCTIONAL CORE (Thick) โ โ โ โ โ โ โ โ โโโโโโโโโโโโโโโโโโโโโโโ โ โ โ โ โ Pure Business โ โ โ โ โ โ Logic & Domain โ โ โ DB โ โ โ โ Rules โ โ โ โ โ โโโโโโโโโโโโโโโโโโโโโโโ โ โ โ โ โ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ โ โ ๐ HTTP ๐ Files ๐ง Email โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
๐ก Tip: Aim for 80% pure code, 20% effects. The more logic you can push into pure functions, the easier testing and reasoning become!
Dependency Management
Traditional OOP: Inject dependencies through constructors or properties
Functional Approach: Pass dependencies as function parameters
// โ OOP style - requires mocking
type UserService(repository: IUserRepository) =
member this.GetUser(id) = repository.FindById(id)
// โ
Functional style - pure and testable
let getUser (findById: int -> User option) (id: int) =
findById id
|> Option.map processUser
In functional architecture, we often create function records to group related dependencies:
type DatabaseOperations = {
findUser: int -> Async<User option>
saveUser: User -> Async<unit>
deleteUser: int -> Async<bool>
}
The Onion Architecture (Functional Style) ๐ง
The onion architecture arranges code in concentric layers, with dependencies pointing inward:
โโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Infrastructure โ
โ (DB, HTTP, Files) โ
โ โโโโโโโโโโโโโโโโโโโโ โ
โ โ Application โ โ
โ โ (Use Cases) โ โ
โ โ โโโโโโโโโโโโโ โ โ
โ โ โ Domain โ โ โ
โ โ โ (Pure) โ โ โ
โ โ โโโโโโโโโโโโโ โ โ
โ โโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโ
Inner layers NEVER depend on outer layers
Domain Layer (Center):
- Pure types and business rules
- No dependencies on external libraries
- Example:
type Order = { Id: int; Items: OrderItem list; Total: decimal }
Application Layer:
- Use cases and workflows
- Composes domain functions
- Defines interfaces for external dependencies
- Example: Order processing pipeline
Infrastructure Layer (Outer):
- Implementations of database access, HTTP clients, etc.
- Framework-specific code
- Instantiates and wires up dependencies
Railway-Oriented Programming ๐
A key pattern for handling errors in functional pipelines:
type Result<'T, 'E> =
| Ok of 'T
| Error of 'E
let bind f result =
match result with
| Ok value -> f value
| Error e -> Error e
let (>>=) = bind
RAILWAY ORIENTED PROGRAMMING
Happy Path: โโโ Validate โโโ Process โโโ Save โโโ โ
Error Path: โ โ โ
โโโโ Error โโโโโโโ Error โโโ โ
This allows composing functions that might fail:
let processOrder orderId =
loadOrder orderId
>>= validateOrder
>>= calculateTotal
>>= applyDiscount
>>= saveOrder
Ports and Adapters (Hexagonal Architecture) ๐ท
Also called Hexagonal Architecture, this pattern isolates core logic from external concerns:
Ports: Interfaces (function signatures) that define what the core needs Adapters: Concrete implementations that fulfill ports
// Port (interface in core)
type IEmailSender =
abstract Send: recipient:string -> subject:string -> body:string -> Async<Result<unit, string>>
// Adapter (implementation in infrastructure)
type SmtpEmailSender() =
interface IEmailSender with
member _.Send recipient subject body =
async {
// SMTP implementation
return Ok ()
}
In F#, we prefer function types over interfaces:
// Port as function type
type SendEmail = string -> string -> string -> Async<Result<unit, string>>
// Adapter as function
let smtpSendEmail: SendEmail =
fun recipient subject body ->
async {
// Implementation
return Ok ()
}
Organizing Modules by Feature ๐
Instead of organizing by technical layer (Models/, Controllers/, Services/), organize by feature or bounded context:
OrderManagement/
โโโ Domain.fs // Pure types and rules
โโโ Operations.fs // Pure business logic
โโโ Validation.fs // Pure validation
โโโ Workflows.fs // Use case orchestration
โโโ Infrastructure.fs // DB, HTTP, etc.
UserManagement/
โโโ Domain.fs
โโโ Operations.fs
โโโ Infrastructure.fs
This approach:
- โ Makes features self-contained
- โ Reduces coupling between features
- โ Simplifies navigation
- โ Enables parallel team work
๐ง Memory Device: Think FOPIS - Feature Organization Promotes Isolation and Scalability!
Handling State ๐
Functional architecture avoids mutable state. Instead:
1. Return new values (immutability):
let updateUserEmail user newEmail =
{ user with Email = newEmail }
2. Thread state through functions:
let processSteps initialState =
initialState
|> step1
|> step2
|> step3
3. Use agents for isolated mutable state:
let cacheAgent = MailboxProcessor.Start(fun inbox ->
let rec loop cache = async {
let! msg = inbox.Receive()
match msg with
| Get (key, reply) ->
reply.Reply(Map.tryFind key cache)
return! loop cache
| Set (key, value) ->
return! loop (Map.add key value cache)
}
loop Map.empty)
Testing Functional Architecture ๐งช
Functional architecture makes testing dramatically simpler:
Pure functions: Test with simple assertions, no mocks needed
[<Fact>]
let ``calculateDiscount applies 10% for orders over 100`` () =
let order = { Total = 150M; Items = [] }
let result = calculateDiscount order
Assert.Equal(135M, result.Total)
Functions with dependencies: Pass test doubles
[<Fact>]
let ``processOrder saves valid order`` () =
let mutable saved = None
let mockSave order = async { saved <- Some order; return Ok () }
processOrder mockSave validOrder |> Async.RunSynchronously
Assert.True(saved.IsSome)
๐ค Did you know? Some functional codebases achieve 100% test coverage of business logic because all the core is pure functions that are trivial to test!
Examples with Explanations
Example 1: E-Commerce Order Processing Pipeline
Let's build a complete order processing system using functional architecture:
// Domain.fs - Pure types, no dependencies
module OrderManagement.Domain
type OrderItem = {
ProductId: int
Quantity: int
Price: decimal
}
type Order = {
Id: int
CustomerId: int
Items: OrderItem list
Total: decimal
Status: OrderStatus
}
and OrderStatus =
| Draft
| Validated
| Paid
| Shipped
| Cancelled
type ValidationError =
| EmptyOrder
| InvalidQuantity of int
| CustomerNotFound of int
// Operations.fs - Pure business logic
module OrderManagement.Operations
open Domain
let calculateTotal (items: OrderItem list) : decimal =
items
|> List.sumBy (fun item -> decimal item.Quantity * item.Price)
let validateOrderItems (items: OrderItem list) : Result<OrderItem list, ValidationError> =
if List.isEmpty items then
Error EmptyOrder
else
let invalidItem = items |> List.tryFind (fun i -> i.Quantity <= 0)
match invalidItem with
| Some item -> Error (InvalidQuantity item.ProductId)
| None -> Ok items
let applyDiscount (order: Order) : Order =
let discount =
if order.Total > 100M then 0.1M
elif order.Total > 50M then 0.05M
else 0M
{ order with Total = order.Total * (1M - discount) }
// Workflows.fs - Use cases orchestration
module OrderManagement.Workflows
open Domain
open Operations
type Dependencies = {
findCustomer: int -> Async<Option<Customer>>
saveOrder: Order -> Async<Result<int, string>>
sendEmail: string -> string -> Async<unit>
}
and Customer = { Id: int; Email: string; Name: string }
let createOrder (deps: Dependencies) (customerId: int) (items: OrderItem list) : Async<Result<Order, ValidationError>> =
async {
// Validate items (pure)
match validateOrderItems items with
| Error e -> return Error e
| Ok validItems ->
// Check customer exists (effect)
let! customerOpt = deps.findCustomer customerId
match customerOpt with
| None -> return Error (CustomerNotFound customerId)
| Some customer ->
// Create and calculate (pure)
let total = calculateTotal validItems
let order = {
Id = 0
CustomerId = customerId
Items = validItems
Total = total
Status = Draft
}
let discountedOrder = applyDiscount order
// Save (effect)
let! saveResult = deps.saveOrder discountedOrder
match saveResult with
| Error msg -> return Error (CustomerNotFound customerId)
| Ok orderId ->
// Notify (effect)
do! deps.sendEmail customer.Email "Order Created"
return Ok { discountedOrder with Id = orderId }
}
// Infrastructure.fs - External implementations
module OrderManagement.Infrastructure
open System.Data.SqlClient
open Domain
let findCustomerDb (connectionString: string) (customerId: int) : Async<Option<Customer>> =
async {
// Database implementation
return Some { Id = customerId; Email = "test@example.com"; Name = "Test" }
}
let saveOrderDb (connectionString: string) (order: Order) : Async<Result<int, string>> =
async {
// Database implementation
return Ok 123
}
let sendEmailSmtp (smtpConfig: SmtpConfig) (recipient: string) (body: string) : Async<unit> =
async {
// SMTP implementation
return ()
}
and SmtpConfig = { Host: string; Port: int }
Explanation: Notice the clear separation:
- Domain.fs: Only types, zero dependencies
- Operations.fs: Pure functions operating on domain types
- Workflows.fs: Orchestrates operations and effects, receives dependencies as parameters
- Infrastructure.fs: Concrete implementations of effects
This structure makes testing trivialโtest pure functions directly, and test workflows with mock dependencies.
Example 2: Dependency Injection with Reader Monad
For complex applications, threading dependencies through every function becomes tedious. The Reader monad solves this elegantly:
type Reader<'env, 'a> = Reader of ('env -> 'a)
let run env (Reader f) = f env
let returnR x = Reader (fun _ -> x)
let bind f (Reader r) =
Reader (fun env ->
let a = r env
let (Reader r2) = f a
r2 env)
type ReaderBuilder() =
member _.Return(x) = returnR x
member _.Bind(r, f) = bind f r
member _.ReturnFrom(r) = r
let reader = ReaderBuilder()
// Environment holds all dependencies
type AppEnvironment = {
Database: DatabaseOperations
EmailService: EmailOperations
Config: AppConfig
}
and DatabaseOperations = {
findUser: int -> Async<User option>
saveUser: User -> Async<unit>
}
and EmailOperations = {
send: string -> string -> Async<unit>
}
and AppConfig = {
MaxRetries: int
Timeout: int
}
and User = { Id: int; Name: string; Email: string }
// Access dependencies within Reader context
let getUserWorkflow userId = reader {
let! env = Reader id // Get environment
let! userOpt = env.Database.findUser userId |> Async.RunSynchronously |> returnR
match userOpt with
| Some user ->
do! env.EmailService.send user.Email "Welcome!" |> Async.RunSynchronously |> returnR
return Some user
| None -> return None
}
// Usage
let env = {
Database = { findUser = findUserDb; saveUser = saveUserDb }
EmailService = { send = sendEmailSmtp }
Config = { MaxRetries = 3; Timeout = 5000 }
}
let result = run env (getUserWorkflow 42)
Explanation: The Reader monad automatically threads the environment through all operations. Functions declare what dependencies they need by accessing the environment, but callers only provide the environment once at the top level.
Example 3: Event Sourcing Architecture
Event sourcing stores state changes as events rather than current state. This fits functional architecture beautifully:
// Domain events (pure data)
type OrderEvent =
| OrderCreated of orderId:int * customerId:int * items:OrderItem list
| OrderValidated of orderId:int
| PaymentReceived of orderId:int * amount:decimal
| OrderShipped of orderId:int * trackingNumber:string
| OrderCancelled of orderId:int * reason:string
// State is derived from events (pure function)
let applyEvent (state: Order option) (event: OrderEvent) : Order option =
match event, state with
| OrderCreated (id, customerId, items), None ->
Some {
Id = id
CustomerId = customerId
Items = items
Total = calculateTotal items
Status = Draft
}
| OrderValidated orderId, Some order when order.Id = orderId ->
Some { order with Status = Validated }
| PaymentReceived (orderId, amount), Some order when order.Id = orderId ->
Some { order with Status = Paid }
| OrderShipped (orderId, tracking), Some order when order.Id = orderId ->
Some { order with Status = Shipped }
| OrderCancelled (orderId, _), Some order when order.Id = orderId ->
Some { order with Status = Cancelled }
| _ -> state
// Rebuild state from event history (pure)
let rehydrate (events: OrderEvent list) : Order option =
events |> List.fold applyEvent None
// Command handlers produce events (business logic)
let handleCreateOrder (customerId: int) (items: OrderItem list) : Result<OrderEvent list, string> =
match validateOrderItems items with
| Error e -> Error (sprintf "Validation failed: %A" e)
| Ok validItems ->
let orderId = generateOrderId() // side effect, but minimal
Ok [ OrderCreated (orderId, customerId, validItems) ]
let handleValidateOrder (order: Order) : Result<OrderEvent list, string> =
if order.Status <> Draft then
Error "Order must be in Draft status"
else
Ok [ OrderValidated order.Id ]
// Event store (infrastructure)
type EventStore = {
save: int -> OrderEvent list -> Async<unit>
load: int -> Async<OrderEvent list>
}
let processCommand (store: EventStore) (orderId: int) (command: Order -> Result<OrderEvent list, string>) : Async<Result<unit, string>> =
async {
// Load history
let! events = store.load orderId
let currentState = rehydrate events
// Execute command
match currentState with
| None -> return Error "Order not found"
| Some order ->
match command order with
| Error e -> return Error e
| Ok newEvents ->
// Save new events
do! store.save orderId newEvents
return Ok ()
}
Explanation: Event sourcing separates:
- Events: Immutable facts about what happened
- State: Derived by folding events (pure function)
- Commands: Business logic that produces events
- Event Store: Infrastructure for persistence
This provides complete audit history and makes testing simpleโjust verify correct events are produced.
Example 4: Capability-Based Security
Instead of checking permissions everywhere, capabilities are functions that can only be created with proper authorization:
// Capabilities are functions that prove authorization
type DeleteUserCapability = int -> Async<Result<unit, string>>
type ViewUserCapability = int -> Async<Result<User, string>>
type UpdateUserCapability = int -> User -> Async<Result<unit, string>>
// Capability provider (infrastructure)
module Capabilities =
let authorize (db: DatabaseOperations) (currentUser: User) : UserCapabilities =
{
deleteUser =
if currentUser.IsAdmin then
Some (fun userId -> db.deleteUser userId)
else
None
viewUser =
Some (fun userId ->
if currentUser.IsAdmin || currentUser.Id = userId then
db.findUser userId
else
async { return Error "Unauthorized" })
updateUser =
Some (fun userId userData ->
if currentUser.Id = userId then
db.saveUser userData
else
async { return Error "Unauthorized" })
}
and UserCapabilities = {
deleteUser: DeleteUserCapability option
viewUser: ViewUserCapability option
updateUser: UpdateUserCapability option
}
// Business logic uses capabilities
let deleteUserWorkflow (capabilities: UserCapabilities) (userId: int) : Async<Result<unit, string>> =
async {
match capabilities.deleteUser with
| None -> return Error "You don't have permission to delete users"
| Some deleteUser ->
let! result = deleteUser userId
return result
}
// Usage
let currentUser = { Id = 5; Name = "Alice"; Email = "alice@example.com"; IsAdmin = true }
let capabilities = Capabilities.authorize dbOps currentUser
let result = deleteUserWorkflow capabilities 42 |> Async.RunSynchronously
Explanation: By making capabilities functions that can only be constructed with proper authorization, we:
- โ Centralize security logic
- โ Make unauthorized operations impossible (type-safe)
- โ Simplify business logic (no permission checks needed)
If you have the capability, you're authorizedโthe type system enforces it!
Common Mistakes โ ๏ธ
Mistake 1: Mixing Pure and Impure Code
โ Wrong:
let processOrder order =
let total = calculateTotal order.Items
saveToDatabase order // Side effect in "pure" function!
total
โ Correct:
let processOrder order =
let total = calculateTotal order.Items
{ order with Total = total }
// Separate function for effects
let saveProcessedOrder order =
let processed = processOrder order
saveToDatabase processed
Why: Mixing effects with logic makes testing hard and reasoning difficult. Keep them separate!
Mistake 2: Over-Using Computation Expressions
Computation expressions (like async, result) are powerful but can make code harder to follow:
โ Wrong (nested computation expressions):
let workflow = async {
let! result1 = async {
let! inner = async {
return! someOperation()
}
return inner
}
return result1
}
โ Correct:
let workflow = async {
let! result = someOperation()
return result
}
// Or simply:
let workflow = someOperation()
Mistake 3: Making Everything a Module Function
Not everything needs to be a standalone module function. Related functions should be grouped:
โ Wrong:
module UserOperations =
let validateEmail email = ...
let validateAge age = ...
let validateName name = ...
// 20 more validation functions
โ Correct:
module UserOperations =
module Validation =
let email e = ...
let age a = ...
let name n = ...
let validateUser user =
{ user with
Email = Validation.email user.Email
Age = Validation.age user.Age
Name = Validation.name user.Name }
Mistake 4: Not Using Type Aliases for Function Signatures
Complex function signatures become unreadable quickly:
โ Wrong:
let processWorkflow
(f1: int -> Async<Result<User, string>>)
(f2: User -> Async<Result<Order, string>>)
(f3: Order -> Async<Result<unit, string>>)
(id: int) =
// implementation
โ Correct:
type FindUser = int -> Async<Result<User, string>>
type CreateOrder = User -> Async<Result<Order, string>>
type SaveOrder = Order -> Async<Result<unit, string>>
let processWorkflow (findUser: FindUser) (createOrder: CreateOrder) (saveOrder: SaveOrder) (id: int) =
// implementation
Mistake 5: Ignoring Error Handling
โ Wrong:
let workflow userId =
let user = findUser userId |> Async.RunSynchronously |> Option.get // Crash!
processUser user
โ Correct:
let workflow userId = async {
let! userOpt = findUser userId
match userOpt with
| None -> return Error "User not found"
| Some user ->
let processed = processUser user
return Ok processed
}
Key Takeaways ๐ฏ
- Functional Core, Imperative Shell: Keep business logic pure, push effects to boundaries
- Dependency Injection: Pass dependencies as function parameters or use Reader monad
- Layer by Feature: Organize code by business domain, not technical layer
- Railway-Oriented Programming: Use
Resulttypes and composition for error handling - Immutability Everywhere: Return new values instead of mutating state
- Types for Design: Use type aliases and domain types to make architecture explicit
- Test Pure Functions: Most of your tests should be simple assertions without mocks
- Capabilities for Security: Make authorization type-safe by encoding permissions as functions
๐ Further Study
- Domain Modeling Made Functional - Comprehensive guide to functional architecture
- F# for Fun and Profit - Railway Oriented Programming - Error handling patterns
- Mark Seemann's Blog - Functional Architecture - Advanced functional design patterns
๐ Quick Reference Card
| Pattern | Purpose | Key Benefit |
| Functional Core / Imperative Shell | Separate pure logic from effects | Easy testing, reasoning |
| Railway-Oriented Programming | Error handling in pipelines | Composable error flow |
| Reader Monad | Dependency injection | Implicit environment threading |
| Onion Architecture | Layer separation | Dependencies point inward |
| Event Sourcing | State from event history | Audit trail, time travel |
| Capability-Based Security | Type-safe authorization | Cannot call unauthorized operations |
| Feature Organization | Group by domain | Low coupling, high cohesion |
๐ง Memory Device - FRICORE:
- Functional core
- Railway-oriented errors
- Immutable state
- Composable functions
- Onion layers
- Reader for dependencies
- Effects at edges