Beginner
This beginner level covers Examples 1-28, reaching approximately 0-35% of software architecture fundamentals. Each example demonstrates a core architectural concept using functional programming idioms. All four languages — F#, Clojure, TypeScript, and Haskell — show the same pattern side by side in tabbed code blocks. These examples target developers who already know at least one language and want to rapidly build architectural instincts through working functional code. Each example uses its own small illustrative domain so the architectural pattern remains the focal point.
Separation of Concerns
Example 1: No Separation vs. Clear Separation
Separation of concerns means grouping code by responsibility so each function handles exactly one aspect of the system. When multiple responsibilities mix in one function, changing any part risks breaking the others. In FP, concerns are separated into distinct named functions rather than classes.
Tightly coupled approach (no separation):
// => This function handles THREE distinct responsibilities at once:
// => 1. Data access (reading from an in-memory map)
// => 2. Business logic (computing discount based on purchase count)
// => 3. Presentation (formatting a string for display)
let getUserDiscountMessage (userId: int) : string =
// => userDb simulates a database lookup — data access concern embedded here
let userDb = Map.ofList [ 1, ("Alice", 12) ]
// => Map.ofList converts a list of key-value pairs to an immutable map
match Map.tryFind userId userDb with
// => Map.tryFind returns Some (name, purchases) or None — safe lookup
| None -> "User not found"
// => None branch: guard for missing user
| Some (name, purchases) ->
// => Business rule embedded directly — hard to change independently
let discount = if purchases > 10 then 0.15 else 0.05
// => 0.15 for loyal customers (>10 purchases), 0.05 default
// => Presentation formatted inline — impossible to reuse discount logic elsewhere
sprintf "Hello %s, your discount is %.0f%%" name (discount * 100.0)
// => Output: "Hello Alice, your discount is 15%"
printfn "%s" (getUserDiscountMessage 1)
// => Output: Hello Alice, your discount is 15%Mixing all three responsibilities means any change — a new discount rule, a different greeting format, or a different data source — requires editing the same function.
Separated approach (three distinct pure functions):
// => DATA ACCESS — only knows how to retrieve users
let findUser (userId: int) : (string * int) option =
// => Returns Some (name, purchases) or None — no formatting, no rules
let userDb = Map.ofList [ 1, ("Alice", 12) ]
// => In-memory map simulates a real data store
Map.tryFind userId userDb
// => Map.tryFind : int -> Map<int,'a> -> 'a option
// => BUSINESS LOGIC — only knows discount rules, not storage or display
let calculateDiscount (purchases: int) : float =
// => Pure function: same input always produces same output
if purchases > 10 then 0.15
// => 15% for loyal customers (>10 purchases)
else 0.05
// => 5% default discount
// => PRESENTATION — only knows how to format, not compute or fetch
let formatDiscountMessage (name: string) (discount: float) : string =
sprintf "Hello %s, your discount is %.0f%%" name (discount * 100.0)
// => Output: "Hello Alice, your discount is 15%"
// => ORCHESTRATION — thin coordinator that pipelines the three functions
let getUserDiscountMessageSeparated (userId: int) : string =
match findUser userId with
// => delegates data access — result is Some (name, purchases) or None
| None -> "User not found"
| Some (name, purchases) ->
let discount = calculateDiscount purchases
// => delegates business rule — discount : float
formatDiscountMessage name discount
// => delegates formatting — returns final string
printfn "%s" (getUserDiscountMessageSeparated 1)
// => Output: Hello Alice, your discount is 15%Each function now has one reason to change: swap the data source without touching the discount rule; change the discount formula without touching the message format.
Key Takeaway: Separate each distinct responsibility into its own named function. A function should have exactly one reason to change.
Why It Matters: In production systems, business rules change far more often than data storage technology, and display formats change more often than both. When these concerns are mixed, a simple business rule change forces a full regression test of the display layer. In FP, the discipline of writing small, pure, single-purpose functions naturally enforces this separation. Functions compose cleanly precisely because each does exactly one thing.
Example 2: Single Responsibility Principle
Paradigm Note: SRP originated in OOP (Robert C. Martin, SOLID). In FP, every function naturally has one responsibility by virtue of being a function — Seemann (SOLID: the next step is Functional, 2014) notes that SRP "collapses to a function signature" in FP. The example below shows the FP form; the OOP framing uses class-level cohesion.
The Single Responsibility Principle (SRP) states that a module or function should have one and only one reason to change. In FP, SRP is expressed through focused modules and single-purpose functions: each module groups only the behavior that belongs together, and an unrelated change to one group never ripples into another. Violating SRP creates fragile code where an unrelated change breaks a seemingly unrelated feature.
Violating SRP — one module does too much:
// => UserManager module handles user data AND email AND password — three reasons to change
module UserManagerBad =
// => users simulates a persistent store
let mutable private users : Map<int, string * string> = Map.empty
// => map from id to (name, email) tuple
let addUser (id: int) (name: string) (email: string) : unit =
users <- Map.add id (name, email) users
// => stores user under id key; mutable state used here
// => EMAIL CONCERN embedded in the user module — mixing responsibilities
let sendWelcomeEmail (userId: int) : unit =
match Map.tryFind userId users with
| None -> ()
// => user not found — silent no-op
| Some (name, email) ->
printfn "Sending email to %s: Welcome, %s!" email name
// => Output: Sending email to alice@example.com: Welcome, Alice!
// => PASSWORD CONCERN also embedded — a third responsibility leaking in
let resetPassword (userId: int) : string =
let newPassword = sprintf "pass_%d_reset" userId
// => deterministic fake password for this example
printfn "Password reset for user %d: %s" userId newPassword
// => Output: Password reset for user 1: pass_1_reset
newPassword
// => returns the new password stringApplying SRP — one module, one responsibility:
// => RESPONSIBILITY 1: User data management only
module UserStore =
// => Immutable store returned on each operation — no mutable shared state
let add (id: int) (name: string) (email: string)
(store: Map<int, string * string>) : Map<int, string * string> =
Map.add id (name, email) store
// => returns a NEW map with the user added — original store unchanged
let get (id: int) (store: Map<int, string * string>) : (string * string) option =
Map.tryFind id store
// => returns Some (name, email) or None — safe lookup
// => RESPONSIBILITY 2: Email notifications only
module EmailService =
let sendWelcome (name: string) (email: string) : unit =
printfn "Sending email to %s: Welcome, %s!" email name
// => Output: Sending email to alice@example.com: Welcome, Alice!
// => This module changes only when email format or provider changes
// => RESPONSIBILITY 3: Password management only
module PasswordService =
let reset (userId: int) : string =
let newPassword = sprintf "pass_%d_reset" userId
// => deterministic for this example; use a CSPRNG in production
printfn "Password reset for user %d: %s" userId newPassword
// => Output: Password reset for user 1: pass_1_reset
newPassword
// => returns generated password string
let store0 = Map.empty
// => empty map is our initial state — no users yet
let store1 = UserStore.add 1 "Alice" "alice@example.com" store0
// => store1 : Map<int, string * string> with one user entry
EmailService.sendWelcome "Alice" "alice@example.com"
// => Output: Sending email to alice@example.com: Welcome, Alice!Key Takeaway: Each module should have exactly one reason to change. When you update email templates, only EmailService changes. When you change password policy, only PasswordService changes.
Why It Matters: SRP is the foundational principle behind microservices — each service owns one business capability. In FP, the natural unit of SRP is the module. Teams that own single-responsibility modules deploy independently, reducing the coordination overhead that kills engineering velocity at scale.
Layered Architecture
Example 3: Three-Layer Architecture
A layered architecture organizes code into a presentation layer (handles user interaction), a business logic layer (enforces rules), and a data access layer (manages persistence). In FP, each layer is a collection of pure functions organized so that data flows only downward — presentation calls business logic, business logic calls data access, never the reverse.
graph TD
A["Presentation Layer<br/>(format / display)"]
B["Business Logic Layer<br/>(pure domain functions)"]
C["Data Access Layer<br/>(find / save functions)"]
A -->|calls| B
B -->|calls| C
C -->|returns data| B
B -->|returns result| A
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#DE8F05,stroke:#000,color:#fff
style C fill:#029E73,stroke:#000,color:#fff
// ============================================================
// DATA ACCESS LAYER — only knows about storage
// ============================================================
module ProductDb =
// => In-memory store simulates a database table
// => Each product has id, name, price, and stock
let private products : Map<int, {| name: string; price: float; stock: int |}> =
Map.ofList [
1, {| name = "Laptop"; price = 1200.0; stock = 5 |}
// => stock 5: available
2, {| name = "Mouse"; price = 25.0; stock = 0 |}
// => stock 0: out of stock
]
let findById (productId: int) =
Map.tryFind productId products
// => returns Some {| name; price; stock |} or None
// => caller decides what to do with the absence
// ============================================================
// BUSINESS LOGIC LAYER — only knows about rules
// ============================================================
module ProductPolicy =
type ProductResult =
| Available of name: string * price: float
// => product exists and is in stock
| OutOfStock of name: string
// => product exists but stock is zero
| NotFound
// => no product found for the given id
let checkAvailability (productId: int) : ProductResult =
match ProductDb.findById productId with
// => delegates data retrieval to the data access layer
| None -> NotFound
// => no product record — return NotFound case
| Some p when p.stock = 0 ->
OutOfStock p.name
// => business rule: zero stock means unavailable
| Some p ->
Available (p.name, p.price)
// => product in stock — return name and price
// ============================================================
// PRESENTATION LAYER — only knows about formatting responses
// ============================================================
module ProductView =
let formatResult (result: ProductPolicy.ProductResult) : string =
match result with
// => pattern match on the union — each case formats differently
| ProductPolicy.Available (name, price) ->
sprintf "Available: %s at $%.2f" name price
// => Output (id=1): "Available: Laptop at $1200.00"
| ProductPolicy.OutOfStock name ->
sprintf "Error: '%s' is out of stock" name
// => Output (id=2): "Error: 'Mouse' is out of stock"
| ProductPolicy.NotFound ->
"Error: Product not found"
// => Output (id=99): "Error: Product not found"
// Wire and run
let display (productId: int) =
productId
|> ProductPolicy.checkAvailability
// => pipes id through business layer
|> ProductView.formatResult
// => pipes result through presentation layer
printfn "%s" (display 1) // => Available: Laptop at $1200.00
printfn "%s" (display 2) // => Error: 'Mouse' is out of stock
printfn "%s" (display 99) // => Error: Product not foundKey Takeaway: Each layer communicates only with the layer directly below it. Presentation never touches the database; data access never formats strings for users. In FP, the |> pipeline operator makes this layered data flow explicit and readable.
Why It Matters: Layered architecture enables parallel development — a frontend team can build against an agreed function signature while a backend team implements the business rules — and makes testing each layer independently straightforward. Because each layer is a collection of pure functions, they can be tested without stubs or mocks.
Example 4: Presentation Layer Isolation
The presentation layer should translate raw input into domain calls and translate domain results into output format. It should contain no business logic and no data access code. Keeping the presentation layer thin means it only threads values through domain functions and formats results — a discipline that FP pipelines make explicit and readable.
// => DATA LAYER — retrieves raw records (pure function, no side effects)
let private orderDb : Map<int, {| total: float; status: string |}> =
Map.ofList [
101, {| total = 299.99; status = "shipped" |}
// => shipped: not eligible for cancellation
102, {| total = 49.0; status = "pending" |}
// => pending with low total: eligible for cancellation
]
let findOrder (orderId: int) =
Map.tryFind orderId orderDb
// => returns Some {| total; status |} or None — pure lookup
// => BUSINESS LAYER — applies domain rules (pure function)
let isEligibleForCancellation (total: float) (status: string) : bool =
status = "pending" && total < 500.0
// => cancellation rule: pending AND total below $500 threshold
// => changing this rule affects only this function
// => PRESENTATION LAYER — translates, never decides
let handleCancelRequest (orderId: int) : string =
match findOrder orderId with
// => fetches from data layer — presentation never queries the map directly
| None ->
sprintf "Order %d not found" orderId
// => presentation transforms None into a user-facing message
| Some order ->
let eligible = isEligibleForCancellation order.total order.status
// => business logic evaluated in business layer, result consumed here
if eligible then
sprintf "Order %d cancelled successfully" orderId
// => Output (id=102): "Order 102 cancelled successfully"
else
sprintf "Order %d cannot be cancelled (status: %s)" orderId order.status
// => Output (id=101): "Order 101 cannot be cancelled (status: shipped)"
printfn "%s" (handleCancelRequest 101) // => Order 101 cannot be cancelled (status: shipped)
printfn "%s" (handleCancelRequest 102) // => Order 102 cancelled successfully
printfn "%s" (handleCancelRequest 999) // => Order 999 not foundKey Takeaway: The presentation layer transforms but never decides. All decisions live in pure business functions where they can be tested without a UI or HTTP context.
Why It Matters: Teams that keep business logic out of presentation functions can test their entire rule set with fast in-memory unit tests. When the presentation layer grows — mobile app, CLI tool, REST API — the business functions require zero modification, because no presentation logic has leaked into them.
MVC Pattern
Example 5: Model-View-Controller Basics
Paradigm Note: MVC originated with Smalltalk (Trygve Reenskaug, 1979) as an OOP pattern. The FP-native equivalent is the Elm Architecture / unidirectional data flow:
model -> update -> viewas pure functions. See the OOP MVC framing for class-based comparison.
MVC separates a program into a Model (data and rules), a View (formatting output), and a Controller (coordinating input and response). In FP, each MVC component is a group of pure functions: the Controller receives input, asks the Model to process it, then passes results to the View for display.
graph LR
User["User Input"]
C["Controller module<br/>(coordinates)"]
M["Model module<br/>(data + rules)"]
V["View module<br/>(formats output)"]
User -->|request| C
C -->|queries / commands| M
M -->|data| C
C -->|data| V
V -->|response| User
style User fill:#CA9161,stroke:#000,color:#fff
style C fill:#0173B2,stroke:#000,color:#fff
style M fill:#DE8F05,stroke:#000,color:#fff
style V fill:#029E73,stroke:#000,color:#fff
// ============================================================
// MODEL — data type + pure rule functions
// ============================================================
module TodoModel =
type Todo = { Id: int; Title: string; Done: bool }
// => record type: Id AND Title AND Done — all required
type Store = { Items: Todo list; NextId: int }
// => immutable store: items list and next available id
let empty = { Items = []; NextId = 1 }
// => empty : Store — initial state with no items
let add (title: string) (store: Store) : Todo * Store =
let item = { Id = store.NextId; Title = title; Done = false }
// => item : Todo — new item with auto-incremented id
let newStore = { Items = store.Items @ [item]; NextId = store.NextId + 1 }
// => @ appends item to the items list
// => NextId incremented for next call
item, newStore
// => returns the created item AND the updated store
let complete (itemId: int) (store: Store) : bool * Store =
let updated = store.Items |> List.map (fun t ->
if t.Id = itemId then { t with Done = true } else t)
// => List.map transforms each item: matching id → Done = true
// => non-matching items pass through unchanged
let found = updated |> List.exists (fun t -> t.Id = itemId)
// => found : bool — true if any item matched the given id
found, { store with Items = updated }
// => returns (success flag, updated store)
// ============================================================
// VIEW — formats data for display, no logic
// ============================================================
module TodoView =
let renderList (items: TodoModel.Todo list) : string =
if List.isEmpty items then "No todos yet."
// => empty list: short message
else
items
|> List.map (fun item ->
let status = if item.Done then "✓" else "○"
// => status is "✓" for done items, "○" for pending
sprintf "[%s] %d. %s" status item.Id item.Title)
// => each item formatted as "[○] 1. Buy milk"
|> String.concat "\n"
// => joined with newlines
let renderCreated (item: TodoModel.Todo) : string =
sprintf "Created todo #%d: %s" item.Id item.Title
// => Output: "Created todo #1: Buy milk"
// ============================================================
// CONTROLLER — coordinates model and view
// ============================================================
module TodoController =
let create (title: string) (store: TodoModel.Store) : string * TodoModel.Store =
let item, newStore = TodoModel.add title store
// => delegates creation to model — receives item + updated store
TodoView.renderCreated item, newStore
// => delegates formatting to view — returns message + store
let listAll (store: TodoModel.Store) : string =
store.Items |> TodoView.renderList
// => fetches items from store, delegates rendering to view
let markDone (itemId: int) (store: TodoModel.Store) : string * TodoModel.Store =
let found, newStore = TodoModel.complete itemId store
// => delegates completion to model
let msg = if found then sprintf "Todo #%d marked as done" itemId
else sprintf "Todo #%d not found" itemId
msg, newStore
// => returns message + updated store
// => Wire the MVC triad together — pure state threading
let msg1, s1 = TodoController.create "Buy milk" TodoModel.empty
// => msg1 = "Created todo #1: Buy milk", s1 has one item
let msg2, s2 = TodoController.create "Write tests" s1
// => msg2 = "Created todo #2: Write tests", s2 has two items
let msg3, s3 = TodoController.markDone 1 s2
// => msg3 = "Todo #1 marked as done", s3 has item 1 with Done = true
printfn "%s" msg1 // => Created todo #1: Buy milk
printfn "%s" msg2 // => Created todo #2: Write tests
printfn "%s" msg3 // => Todo #1 marked as done
printfn "%s" (TodoController.listAll s3)
// => [✓] 1. Buy milk
// => [○] 2. Write testsKey Takeaway: The Controller handles input and coordinates. The Model owns data types and rules. The View formats output. In FP, state flows explicitly from function to function rather than being mutated in place, making the data flow visible and testable.
Why It Matters: MVC is the backbone of virtually every web framework. Understanding the pure form of MVC lets you debug framework issues quickly. The FP approach makes each layer's role obvious through function signatures — a View function accepts data and returns a string, not a database object.
Example 6: Model Encapsulates Validation
Paradigm Note: In OOP, validation lives inside model methods. The FP-native form is "parse, don't validate" (Wlaschin, Designing with types): use smart constructors + ADTs so invalid states are unrepresentable at the type level. See the OOP framing.
The Model is responsible for enforcing its own invariants. A smart constructor — a function that validates inputs before producing a value — ensures that invalid data can never be constructed. The type system (or convention, depending on the language) enforces the rule, not runtime guards scattered across callers.
// => POOR APPROACH: raw record with no invariant enforcement
// => Any code can construct a BankAccount with negative balance
type PoorBankAccount = { Balance: float }
// => Nothing stops: { Balance = -9999.0 }
// => Callers must remember to validate themselves — drift is inevitable
// => ENCAPSULATED APPROACH: module with opaque type and smart constructor
module BankAccount =
// => Opaque type — Balance field is accessible but construction is controlled
type T = private { Balance: float }
// => private label: only this module can construct a T record directly
let create (initialBalance: float) : Result<T, string> =
if initialBalance < 0.0 then
Error "Initial balance cannot be negative"
// => enforced at construction time — invalid state never enters the system
else
Ok { Balance = initialBalance }
// => Ok wraps the valid account — callers must handle the Result
let deposit (amount: float) (account: T) : Result<T, string> =
if amount <= 0.0 then
Error "Deposit amount must be positive"
// => model rejects invalid inputs without caller involvement
else
Ok { Balance = account.Balance + amount }
// => returns a NEW account with updated balance — immutable update
let withdraw (amount: float) (account: T) : Result<T, string> =
if amount <= 0.0 then
Error "Withdrawal amount must be positive"
elif account.Balance - amount < 0.0 then
Error (sprintf "Insufficient funds: balance is %.2f" account.Balance)
// => business rule enforced in model — callers cannot bypass
else
Ok { Balance = account.Balance - amount }
// => returns new account with reduced balance
let balance (account: T) : float = account.Balance
// => read-only accessor — caller cannot modify Balance directly
// => USAGE: composition root creates and threads accounts
let result =
BankAccount.create 100.0
// => Ok { Balance = 100.0 }
|> Result.bind (BankAccount.withdraw 50.0)
// => Ok { Balance = 50.0 }
|> Result.bind (BankAccount.withdraw 200.0)
// => Error "Insufficient funds: balance is 50.00"
match result with
| Ok acc -> printfn "Balance: $%.2f" (BankAccount.balance acc)
| Error e -> printfn "Error: %s" e
// => Output: Error: Insufficient funds: balance is 50.00Key Takeaway: Use a module with a private constructor and a smart create function to enforce invariants at the type level. A successfully constructed value is always valid — no external guard required.
Why It Matters: Domain model integrity is the first line of defense against data corruption in production. When the model enforces its own rules through the type system, invariants cannot be violated even by callers who forget to validate. A sum type such as Result/Either makes the possibility of failure explicit in the function signature, so callers cannot ignore it.
Dependency Injection
Example 7: Manual Dependency Injection
Dependency injection means passing dependencies into a function rather than hard-coding them inside it. In FP, DI is natural: functions take their dependencies as parameters — typically as function-typed arguments or records of functions — so the caller decides which implementation to supply. This makes code testable without any DI framework.
graph TD
Caller["Caller / Composition Root"]
Store["User Store (real or fake)"]
Svc["greetUser function"]
Caller -->|creates and passes| Store
Caller -->|calls with Store injected| Svc
Svc -->|uses injected Store| Store
style Caller fill:#0173B2,stroke:#000,color:#fff
style Store fill:#029E73,stroke:#000,color:#fff
style Svc fill:#DE8F05,stroke:#000,color:#fff
Without dependency injection (hard-coded dependency):
// => Hard-coded dependency: greetUser always queries this specific map
// => Cannot be tested without the real data store
let greetUserHardcoded (userId: int) : string =
let db = Map.ofList [ 1, "Alice"; 2, "Bob" ]
// => dependency created inside function — impossible to substitute in tests
match Map.tryFind userId db with
| None -> sprintf "User %d not found" userId
| Some name -> sprintf "Hello, %s!" name
// => Output: "Hello, Alice!"With dependency injection (easy to test with any backend):
// => TYPE ALIAS for the dependency — any function matching this signature works
type UserFetcher = int -> string option
// => int -> string option means: given an id, produce an optional name
// => REAL implementation for production
let realUserFetcher : UserFetcher =
let data = Map.ofList [ 1, "Alice"; 2, "Bob" ]
// => captures the data map in a closure — simulates a real DB
fun userId -> Map.tryFind userId data
// => returns Some "Alice" or None — delegates to map lookup
// => FAKE implementation for tests — no DB required
let fakeUserFetcher : UserFetcher =
fun _ -> Some "TestUser"
// => always returns "TestUser" regardless of id — predictable in tests
// => SERVICE: accepts any UserFetcher — decoupled from specific implementation
let greetUser (fetchUser: UserFetcher) (userId: int) : string =
match fetchUser userId with
// => delegates lookup to whatever fetcher was injected
| None -> sprintf "User %d not found" userId
| Some name -> sprintf "Hello, %s!" name
// => Output: "Hello, Alice!"
// => PRODUCTION: inject real fetcher
printfn "%s" (greetUser realUserFetcher 1) // => Hello, Alice!
printfn "%s" (greetUser realUserFetcher 99) // => User 99 not found
// => TEST: inject fake fetcher — no database needed
printfn "%s" (greetUser fakeUserFetcher 1) // => Hello, TestUser!Key Takeaway: Inject dependencies as function parameters rather than hard-coding them. The function only knows the signature it needs, not which implementation provides it.
Why It Matters: In FP, every function that accepts a function parameter is implicitly practicing dependency injection. This makes testability the default: swap the real fetcher for a fake by passing a different function. No DI container, no reflection — just higher-order functions, which all four languages support natively.
Example 8: Constructor Injection vs. Method Injection
Paradigm Note: Constructors are an OOP class-lifecycle concept. The FP equivalent for "constructor injection" is partial application (passing dependencies first); for "method injection" it is passing the dependency as a function parameter at call site, or using a Reader monad. The example below shows the FP-native forms; for the OOP class-constructor framing see the sibling tutorial.
There are two common styles of dependency injection: constructor injection (dependencies fixed when an object is built) and method injection (dependencies passed per-call). In F#, both patterns appear as partial application (fixing some arguments upfront) versus full parameter threading (passing on every call). In Clojure, the same split maps to closing over dependencies in a factory function versus threading the dependency as a final argument on every call.
// => PARTIAL APPLICATION AS "CONSTRUCTOR" INJECTION
// => Use when: dependency is always required and does not change per call
let makeOrderProcessor (charge: float -> bool) : (int -> float -> string) =
// => charge is fixed at "construction" time via partial application
// => returns a new function with charge baked in
fun orderId amount ->
let success = charge amount
// => uses the injected charge function — no knowledge of which gateway
if success then sprintf "Order %d paid ($%.0f)" orderId amount
else sprintf "Order %d payment failed" orderId
// => Output (success): "Order 1 paid ($500)"
// => METHOD INJECTION (per-call dependency passing)
// => Use when: dependency varies per request (e.g., per-user logger)
let logMessage (message: string) (output: string -> unit) : unit =
output (sprintf "[AUDIT] %s" message)
// => output is passed per call — can be console, file, test spy, etc.
// => Output: "[AUDIT] User login"
// => USAGE: wire concrete implementations at the composition root
// => "Constructor" injection via partial application
let fakeCharge = fun amount -> amount < 1000.0
// => fakeCharge returns true for amounts < 1000 (simulates approval limit)
let processOrder = makeOrderProcessor fakeCharge
// => processOrder is now a (int -> float -> string) with charge baked in
printfn "%s" (processOrder 1 500.0) // => Order 1 paid ($500)
printfn "%s" (processOrder 2 1500.0) // => Order 2 payment failed
// => Method injection — output function varies per call
logMessage "User login" (printfn "%s")
// => Output: [AUDIT] User login
logMessage "File export" (fun msg -> printfn ">> %s" msg)
// => Output: >> [AUDIT] File exportKey Takeaway: Use partial application (F#) or factory closures (Clojure) to fix stable dependencies at "construction" time. Use full parameter threading when the dependency varies per invocation.
Why It Matters: Partial application and factory closures are the FP form of constructor injection — they bake a dependency into a function, producing a simpler function that already has what it needs. F#, Clojure, TypeScript, and Haskell all express this pattern naturally through first-class functions. Method injection powers extensible APIs: passing different output functions lets audit logging write to a console, a file, or a test spy without changing the logging logic.
Interface Segregation
Example 9: Interface Segregation Principle
Paradigm Note: ISP guards against fat nominal interfaces in OOP. In FP, each function naturally takes only the type it needs — ISP "collapses to function type signatures" (Seemann, SOLID: the next step is Functional). See the OOP framing.
The Interface Segregation Principle says that modules should not depend on operations they do not use. In FP, this is expressed naturally through focused record-of-functions types or segregated protocols: each consumer receives only the functions it actually needs, not a large monolithic record. Splitting a fat dependency record into smaller focused ones means no consumer is forced to provide stub implementations for capabilities it does not possess.
Fat dependency record — forces consumers to carry functions they do not use:
// => FAT dependency record: all consumers must provide ALL four functions
type EmployeeOperations = {
CalculateSalary: unit -> float
// => relevant for paid employees
ClockIn: unit -> unit
// => relevant for hourly workers
GenerateReport: unit -> string
// => relevant for managers
RequestLeave: int -> unit
// => relevant for all employees
}
// => CONTRACTOR only needs salary calculation
// => yet must provide all four — clockIn and generateReport are forced stubs
let contractorOps : EmployeeOperations = {
CalculateSalary = fun () -> 500.0
// => useful: contractor's daily rate
ClockIn = fun () -> ()
// => forced but meaningless for a contractor
GenerateReport = fun () -> ""
// => forced but meaningless for a contractor
RequestLeave = fun _ -> ()
// => forced but meaningless — contractors don't accrue leave
}Segregated dependency types — each consumer picks only what applies:
// => FOCUSED dependency types: each covers one capability
type Payable = { CalculateSalary: unit -> float }
// => pay calculation only
type Trackable = { ClockIn: unit -> unit }
// => time tracking only
type Reportable = { GenerateReport: unit -> string }
// => reporting only
// => CONTRACTOR: only salary matters, no forced stubs
let segContractor : Payable = { CalculateSalary = fun () -> 500.0 }
// => flat daily rate — contractor record has exactly one field
// => FULL-TIME EMPLOYEE: salary + time tracking + leave (separate records)
let mutable hoursWorked = 0
// => mutable local for this demonstration
let ftPayable : Payable = { CalculateSalary = fun () -> float hoursWorked * 25.0 }
// => $25/hour — only salary concern
let ftTrackable : Trackable = { ClockIn = fun () -> hoursWorked <- hoursWorked + 8 }
// => adds one full work day per clock-in — only tracking concern
// => MANAGER: salary + reporting
let mgPayable : Payable = { CalculateSalary = fun () -> 8000.0 }
// => fixed monthly salary
let mgReportable : Reportable = { GenerateReport = fun () -> "Team performance: on track" }
// => manager-specific report — only reporting concern
printfn "Contractor salary: %.0f" (segContractor.CalculateSalary ())
// => Output: Contractor salary: 500
ftTrackable.ClockIn ()
// => hoursWorked is now 8
printfn "FT salary: %.0f" (ftPayable.CalculateSalary ())
// => Output: FT salary: 200 (8 hours * $25)Key Takeaway: Split dependency records (F#) or protocols (Clojure) by cohesive capability, not by the most complex consumer. Each consumer receives only the functions it genuinely uses.
Why It Matters: Interface segregation is why record-of-functions and protocol-based DI are powerful mechanisms — you compose only the capabilities a function needs. Systems that ignore ISP accumulate unused fields in large dependency records, increasing coupling and making substitution harder. Focused records and protocols make dependencies explicit and minimal across F#, Clojure, TypeScript, and Haskell alike.
Open/Closed Principle
Example 10: Open for Extension, Closed for Modification
Paradigm Note: OCP was coined by Bertrand Meyer for OOP inheritance hierarchies. The FP equivalent uses typeclasses (Haskell), protocols (Clojure — see Rich Hickey's Simple Made Easy), or discriminated unions with exhaustive matching. Both axes of the Expression Problem are addressed differently in FP — see the OOP framing.
The Open/Closed Principle states that a function or module should be open for extension (new behaviors can be added) but closed for modification (existing code does not change when behavior is added). In FP, this is achieved through function parameters and sum types: you extend by passing a new function value or adding a new variant, not by editing existing logic. Each new strategy is a new value; the dispatch or consumer function is untouched.
graph TD
Client["Client Code"]
Base["Discount Strategy<br/>(function type)"]
A["regularDiscount"]
B["loyaltyDiscount"]
C["seasonalDiscount"]
Client -- "calls with" --> Base
Base -- "implemented by" --> A
Base -- "implemented by" --> B
Base -- "implemented by" --> C
style Client fill:#0173B2,stroke:#000,color:#fff
style Base fill:#DE8F05,stroke:#000,color:#fff
style A fill:#029E73,stroke:#000,color:#fff
style B fill:#029E73,stroke:#000,color:#fff
style C fill:#029E73,stroke:#000,color:#fff
Closed approach — requires modifying existing code for every new discount:
// => VIOLATION: adding a new discount type requires editing this function
let calculateDiscountBad (price: float) (discountType: string) : float =
match discountType with
| "regular" -> price * 0.10
// => 10% off
| "loyalty" -> price * 0.20
// => 20% off for loyal customers
// => Every new discount type requires adding another match arm here
// => This is the "open for modification" anti-pattern
| _ -> 0.0
// => default: no discount — adding "seasonal" means editing this functionOpen/Closed approach — extend by adding new functions, never editing existing ones:
// => STRATEGY TYPE: defines the contract — any float -> float function qualifies
type DiscountStrategy = float -> float
// => Takes a price, returns the discount amount — simple and composable
// => CONCRETE STRATEGIES — add new ones without touching existing code
let regularDiscount : DiscountStrategy = fun price -> price * 0.10
// => 10% discount
let loyaltyDiscount : DiscountStrategy = fun price -> price * 0.20
// => 20% discount for loyal customers
// => EXTENSION: new discount type — existing strategies are UNTOUCHED
let seasonalDiscount : DiscountStrategy = fun price -> price * 0.30
// => 30% seasonal sale discount — added without modifying regularDiscount or loyaltyDiscount
// => CLIENT: accepts any DiscountStrategy — closed to modification, open to extension
let finalPrice (strategy: DiscountStrategy) (price: float) : float =
let discount = strategy price
// => delegates discount computation to injected strategy
price - discount
// => price minus discount amount
printfn "%.1f" (finalPrice regularDiscount 100.0)
// => 100 - 10 = 90.0
printfn "%.1f" (finalPrice seasonalDiscount 100.0)
// => 100 - 30 = 70.0
// => COMPOSING strategies: combine two strategies (e.g., loyalty + seasonal)
let combinedDiscount (strategies: DiscountStrategy list) : DiscountStrategy =
fun price ->
strategies |> List.sumBy (fun s -> s price)
// => sums all discounts — extensible: add more strategies to the list
printfn "%.1f" (finalPrice (combinedDiscount [regularDiscount; loyaltyDiscount]) 100.0)
// => 100 - (10 + 20) = 70.0Key Takeaway: Depend on function types and inject concrete strategies from outside. Adding new behavior means writing a new function, not modifying existing ones.
Why It Matters: The Open/Closed Principle enables extension through new implementations rather than modification. In all four FP languages, a strategy type is any function matching the required signature, so every new discount is a new value — no edits to the dispatch logic. Pluggable systems — payment processors, notification channels — are only maintainable when each extension point is a function parameter rather than a conditional.
Liskov Substitution Principle
Example 11: Subtypes Must Be Substitutable
The Liskov Substitution Principle (LSP) says that any value of a subtype must be usable wherever its parent type is expected, without breaking the program. In FP, LSP is expressed through parametric polymorphism and sum types: a function that pattern-matches on a closed sum of variants can receive any conforming value safely. FP avoids LSP violations by preferring function types and algebraic data types over inheritance hierarchies — every variant carries exactly the data it needs, so no case can silently break the contract expected by a consumer.
LSP violation modeled — subtype breaks the contract:
// => Simulating the OOP Rectangle/Square LSP violation using mutable records
// => to illustrate WHY F# prefers composition over inheritance
type MutableRectangle = { mutable Width: float; mutable Height: float }
// => mutable fields allow the violation to be demonstrated
// => "Base type" contract: setWidth and setHeight change only one dimension each
let setWidth (r: MutableRectangle) (w: float) = r.Width <- w
// => intended: only Width changes
let setHeight (r: MutableRectangle) (h: float) = r.Height <- h
// => intended: only Height changes
// => "Square" variant VIOLATES the contract by coupling Width and Height
let makeSquare side = { Width = side; Height = side }
// => both dimensions equal — fine for a square, but…
let squareLike = makeSquare 1.0
setWidth squareLike 5.0
// => changes Width to 5.0, but Height stays 1.0 in this representation
// => a "true square" mutator would also set Height = 5.0 — breaking the Rectangle contract
setHeight squareLike 3.0
printfn "Expected area 15.0, got: %.1f" (squareLike.Width * squareLike.Height)
// => Output: Expected area 15.0, got: 15.0 — coincidence; the mutable contract is fragileLSP-compliant design — use a discriminated union (F#) or multimethod (Clojure), not inheritance:
// => SHAPE union: each case carries exactly the data it needs
type Shape =
| Rectangle of width: float * height: float
// => Rectangle case: independent width and height
| Square of side: float
// => Square case: single side length — no inherited dimension confusion
// => AREA function: works for any Shape — LSP satisfied at the type level
let area (shape: Shape) : float =
match shape with
| Rectangle (w, h) -> w * h
// => width * height — independent dimensions
| Square s -> s * s
// => side * side — no ambiguity
printfn "Area: %.1f" (area (Rectangle (5.0, 3.0)))
// => Output: Area: 15.0
printfn "Area: %.1f" (area (Square 4.0))
// => Output: Area: 16.0
// => GENERIC CONSTRAINT substitutability example
let describeArea<'a when 'a : equality> (toArea: 'a -> float) (shapes: 'a list) : unit =
shapes |> List.iter (fun s -> printfn "Area: %.1f" (toArea s))
// => any type with an area function qualifies — parametric polymorphism enforces LSP
describeArea area [Rectangle (5.0, 3.0); Square 4.0; Rectangle (2.0, 6.0)]
// => Area: 15.0
// => Area: 16.0
// => Area: 12.0Key Takeaway: Prefer algebraic data types and pattern matching over inheritance hierarchies. When every case carries exactly the data it needs, no case can break the contract expected by a consumer.
Why It Matters: LSP violations create runtime surprises that escape static analysis. The classic Rectangle/Square problem in OOP surfaces whenever mutable coupling is hidden behind an inheritance contract. Sum types — discriminated unions, tagged unions, ADTs — eliminate this by making each variant structurally independent: no case can silently break another case's contract.
DRY, KISS, and YAGNI
Example 12: DRY — Don't Repeat Yourself
DRY (Don't Repeat Yourself) means every piece of knowledge should have a single authoritative representation. In FP, duplication is eliminated by extracting shared rules into named functions and composing them — rather than copy-pasting conditional logic — so that every caller delegates to the single canonical definition.
Violation — business rule duplicated in three places:
// => VIOLATION: the "eligible user" rule is duplicated in every function
// => If the rule changes (e.g., add emailVerified check), all three must be updated
let sendNotification (name: string) (active: bool) (age: int) : unit =
if active && age >= 18 then // => rule duplicated here
printfn "Notifying %s" name
// => Output: Notifying Alice
let generateReport (name: string) (active: bool) (age: int) : unit =
if active && age >= 18 then // => same rule repeated
printfn "Report for %s" name
let allowPurchase (active: bool) (age: int) : bool =
active && age >= 18 // => rule duplicated a third timeDRY — single authoritative location for the rule:
// => SINGLE SOURCE OF TRUTH: rule defined once as a named function
let isEligibleUser (active: bool) (age: int) : bool =
active && age >= 18
// => returns true only if active AND adult
// => changing this rule updates all three callers automatically
// => Each function delegates to the single rule
let sendNotificationDry (name: string) (active: bool) (age: int) : unit =
if isEligibleUser active age then // => delegates to single rule
printfn "Notifying %s" name
// => Output: Notifying Alice
let generateReportDry (name: string) (active: bool) (age: int) : unit =
if isEligibleUser active age then // => same single rule
printfn "Report for %s" name
let allowPurchaseDry (active: bool) (age: int) : bool =
isEligibleUser active age // => single rule, no duplication
sendNotificationDry "Alice" true 25
// => Output: Notifying Alice
printfn "%b" (allowPurchaseDry true 25)
// => Output: true
printfn "%b" (allowPurchaseDry false 25)
// => Output: falseKey Takeaway: Extract repeated decisions into named functions. Code duplication is a symptom of knowledge duplication — fix the knowledge location, not just the syntax.
Why It Matters: The most costly bugs in production are consistency bugs where the same rule was updated in two places but not the third. DRY violations are the primary driver of those failures. In FP, extracting a rule into a named function is frictionless — partial application and first-class functions let you pre-bind arguments or pass the extracted predicate directly to higher-order combinators, making the extraction equally zero-friction across F#, Clojure, TypeScript, and Haskell.
Example 13: KISS — Keep It Simple, Stupid
KISS means preferring the simplest design that satisfies the requirements. Complexity is a cost that must be justified by demonstrable benefit. Over-engineering in FP often appears as unnecessary type machinery — discriminated unions, protocol hierarchies, or spec wrapping — for a problem that a single pure function solves directly.
Over-engineered — excessive type machinery for a simple task:
// => OVER-ENGINEERED: discriminated union + factory + builder pattern for a simple greeting
type GreetingStyle = Formal | Casual
// => Union type adds no value when there is only one consumer
type GreetingConfig = { Style: GreetingStyle; Prefix: string }
// => Config record: one more layer of indirection
let makeGreetingConfig (style: GreetingStyle) : GreetingConfig =
match style with
| Formal -> { Style = Formal; Prefix = "Good day" }
// => factory function creates config from style
| Casual -> { Style = Casual; Prefix = "Hey" }
let greetFromConfig (config: GreetingConfig) (name: string) : string =
sprintf "%s, %s." config.Prefix name
// => uses prefix from config record
// => Usage: 3 types + 2 functions + 1 config to print "Good day, Alice."
let cfg = makeGreetingConfig Formal
printfn "%s" (greetFromConfig cfg "Alice")
// => Output: Good day, Alice.
// => Five declarations to do what one function doesKISS — simplest solution that works:
// => SIMPLE: one function, zero ceremony, achieves the same result
let greet (name: string) : string =
sprintf "Good day, %s." name
// => Output: Good day, Alice.
// => If greeting styles are needed later, add them then (YAGNI)
printfn "%s" (greet "Alice")
// => Output: Good day, Alice.Key Takeaway: Add abstractions only when complexity is demonstrated, not anticipated. The simple solution is easier to read, debug, test, extend, and hand off.
Why It Matters: Premature abstraction is one of the top causes of architectural debt. Every FP language offers powerful abstraction tools — sum types, type aliases, monadic computation, protocols, typeclasses — but each addition carries a maintenance cost. Build the simplest thing, then refactor when a pattern genuinely emerges from repeated use, not from speculation.
Example 14: YAGNI — You Aren't Gonna Need It
YAGNI means do not add functionality until it is actually needed. Speculative features add code complexity without delivering current value, and they are often built for a requirement that never arrives in the form anticipated. In FP, speculative fields in records or maps add invisible surface area — every function that reads the data structure must mentally filter out keys it does not need.
// => YAGNI VIOLATION: speculative features not required by any current use case
type SpeculativeUserProfile = {
Name: string
// => required today
Email: string
// => required today
// => SPECULATIVE fields: no current feature requires these
Theme: string
// => "might need dark mode someday"
PreferredLanguage: string
// => "maybe we'll go international"
NewsletterFrequency: string
// => "for a newsletter we haven't built"
AiRecommendations: bool
// => "for an AI feature in the roadmap"
}
// => The speculative fields force every test and constructor to supply values
// => that have no business meaning yet — pure noise in the codebase
// ============================================================
// => YAGNI COMPLIANT: only what the application actually needs right now
type SimpleUserProfile = {
Name: string
// => required today
Email: string
// => required today
// => No speculative fields — add when a feature actually needs them
}
let displayName (profile: SimpleUserProfile) : string =
profile.Name
// => required today for display — Output: "Alice"
// => Add exportToXml when an export feature is actually built
// => Add theme when dark mode is actually shipped
let user = { Name = "Alice"; Email = "alice@example.com" }
// => construction is trivial — no speculative fields to supply
printfn "%s" (displayName user)
// => Output: AliceKey Takeaway: Ship only what the current requirement demands. Code that is never executed in production still costs maintenance, testing, and cognitive load.
Why It Matters: YAGNI reduces the "inventory" of unvalidated code. Speculative record fields force every constructor and pattern match to handle values that may never be used; speculative map keys force every reader to mentally filter noise. This holds equally in F#, Clojure, TypeScript, and Haskell. Lean manufacturing teaches that inventory is waste — software inventory (unshipped features, speculative fields) follows the same economics.
Coupling and Cohesion
Example 15: High Coupling — The Problem
Coupling measures how much one module depends on the internals of another. High coupling means a change in one module forces changes in others, making the system brittle and hard to evolve. In FP, high coupling appears when one function directly accesses or mutates the internal fields of data structures owned by another module — bypassing the stable function interface that module exposes.
// => HIGH COUPLING: placeOrder reads the internals of both customer and inventory records
type Customer = {
Name: string
CreditLimit: float
// => exposed field — any caller can depend on this name
mutable OutstandingBalance: float
// => mutable field — any caller can mutate this directly
}
type Inventory = {
mutable Items: Map<string, int>
// => exposed mutable map — any caller can manipulate stock directly
}
// => placeOrder KNOWS about Customer's fields AND Inventory's internals
let tightlyCoupledPlaceOrder
(customer: Customer) (inventory: Inventory)
(item: string) (price: float) : string =
// => directly reads customer's internal fields — tight coupling
if customer.OutstandingBalance + price > customer.CreditLimit then
"Credit limit exceeded"
// => directly reads inventory's internal map — tight coupling
elif inventory.Items |> Map.tryFind item |> Option.defaultValue 0 <= 0 then
sprintf "%s is out of stock" item
else
// => directly mutates customer's internal field
customer.OutstandingBalance <- customer.OutstandingBalance + price
// => directly mutates inventory's internal map
inventory.Items <- Map.add item (inventory.Items.[item] - 1) inventory.Items
sprintf "Order placed: %s for %s" item customer.Name
// => Output: "Order placed: Laptop for Alice"
let customer = { Name = "Alice"; CreditLimit = 2000.0; OutstandingBalance = 0.0 }
let inventory = { Items = Map.ofList [ "Laptop", 5 ] }
printfn "%s" (tightlyCoupledPlaceOrder customer inventory "Laptop" 1200.0)
// => Order placed: Laptop for Alice
// => If Customer renames CreditLimit to AvailableCredit, placeOrder breaks
// => If Inventory changes Items to a database call, placeOrder breaksKey Takeaway: When one function directly reads or mutates another module's internal fields, every internal change cascades as a breaking change throughout the codebase.
Why It Matters: High coupling is the primary reason legacy migrations fail. Systems where functions directly manipulate each other's internals cannot be changed one component at a time. In FP, preferring immutable values returned by smart functions — rather than mutable shared fields — is the idiomatic way to prevent coupling from taking root.
Example 16: Low Coupling Through Encapsulation
Paradigm Note: Encapsulation in OOP hides mutable state behind methods. In FP, low coupling comes from pure functions + module boundaries — there is no mutable state to hide. See the OOP framing.
Reducing coupling means modules communicate through stable function interfaces, not through internal fields. In FP, encapsulation is achieved by giving each domain concept its own module with opaque types and smart accessor/mutator functions — callers depend on behavior, not representation.
graph LR
OS["placeOrder function"]
C["Customer module<br/>(encapsulated)"]
I["Inventory module<br/>(encapsulated)"]
OS -->|canAcceptCharge| C
OS -->|recordCharge| C
OS -->|isAvailable| I
OS -->|decrement| I
style OS fill:#0173B2,stroke:#000,color:#fff
style C fill:#DE8F05,stroke:#000,color:#fff
style I fill:#029E73,stroke:#000,color:#fff
// => ENCAPSULATED Customer module — hides internals behind stable functions
module Customer =
type T = private { Name: string; CreditLimit: float; Balance: float }
// => private constructor: external code cannot construct or destructure directly
let create (name: string) (creditLimit: float) : T =
{ Name = name; CreditLimit = creditLimit; Balance = 0.0 }
// => smart constructor: enforces valid initial state
let name (c: T) : string = c.Name
// => read-only accessor — callers cannot reach the Name field directly
let canAcceptCharge (amount: float) (c: T) : bool =
c.Balance + amount <= c.CreditLimit
// => hides the credit logic — callers don't know the formula
let recordCharge (amount: float) (c: T) : T =
{ c with Balance = c.Balance + amount }
// => returns a NEW customer with updated balance — immutable update
// => internal representation could change without affecting callers
// => ENCAPSULATED Inventory module — hides the backing data structure
module Inventory =
type T = private { Stock: Map<string, int> }
// => private constructor: external code cannot access Stock directly
let create (items: (string * int) list) : T =
{ Stock = Map.ofList items }
// => smart constructor: builds inventory from a list of (item, qty) pairs
let isAvailable (item: string) (inv: T) : bool =
inv.Stock |> Map.tryFind item |> Option.defaultValue 0 > 0
// => hides how stock is stored — could be a database call
let decrement (item: string) (inv: T) : T =
match Map.tryFind item inv.Stock with
| Some qty when qty > 0 ->
{ inv with Stock = Map.add item (qty - 1) inv.Stock }
// => returns new inventory with decremented count
| _ -> inv
// => no-op if item is missing or at zero
// => LOOSELY COUPLED placeOrder — talks to module interfaces only
let placeOrder
(customer: Customer.T) (inv: Inventory.T)
(item: string) (price: float)
: string * Customer.T * Inventory.T =
if not (Customer.canAcceptCharge price customer) then
"Credit limit exceeded", customer, inv
// => no internal field access — delegates to Customer module
elif not (Inventory.isAvailable item inv) then
sprintf "%s is out of stock" item, customer, inv
else
let c2 = Customer.recordCharge price customer
// => delegates mutation to Customer module
let i2 = Inventory.decrement item inv
// => delegates mutation to Inventory module
sprintf "Order placed: %s for %s" item (Customer.name customer), c2, i2
// => Output: "Order placed: Laptop for Alice"
let c = Customer.create "Alice" 2000.0
let i = Inventory.create [ "Laptop", 5 ]
let msg, _, _ = placeOrder c i "Laptop" 1200.0
printfn "%s" msg
// => Order placed: Laptop for Alice
// => Renaming CreditLimit to AvailableCredit has ZERO impact on placeOrderKey Takeaway: Define stable module functions that express what a concept can do, not what it contains. Callers depend on behavior, not representation.
Why It Matters: Encapsulation is what makes refactoring safe. When the internal representation of a domain type changes — say, Balance switches numeric types — only that module changes. Systems with high encapsulation maintain a stable change cost over time.
Example 17: Cohesion — Grouping Related Behavior
Cohesion measures how related the responsibilities within a module are. High cohesion means everything in a module belongs together; low cohesion means the module mixes unrelated concerns.
Low cohesion:
// => LOW COHESION: MixedUtils handles three completely unrelated domains
module MixedUtils =
let formatTitle (title: string) : string =
title.ToUpperInvariant ()
// => "hello" → "HELLO" — string manipulation concern
let calculateTax (price: float) (rate: float) : float =
price * rate
// => 100 * 0.1 = 10.0 — financial calculation, unrelated to strings
let isWeekend (date: System.DateTime) : bool =
date.DayOfWeek = System.DayOfWeek.Saturday ||
date.DayOfWeek = System.DayOfWeek.Sunday
// => date logic — unrelated to both of the aboveHigh cohesion:
// => HIGH COHESION: each module groups only related behavior
// => REASON: when formatTitle changes, it does not affect tax or date logic
module StringFormatter =
// => All functions relate to text formatting
let formatTitle (title: string) : string =
title.ToUpperInvariant ()
// => "hello" → "HELLO"
let truncate (maxLength: int) (text: string) : string =
if text.Length > maxLength then text.[0..maxLength-1] + "…"
// => "Hello World" with maxLength=5 → "Hello…"
else text
// => text shorter than maxLength passes through unchanged
module TaxCalculator =
// => All functions relate to tax computation
let calculate (rate: float) (price: float) : float =
price * rate
// => 100 * 0.1 = 10.0
let calculateWithCap (rate: float) (cap: float) (price: float) : float =
let tax = price * rate
// => 100 * 0.15 = 15.0
min tax cap
// => min(15.0, 10.0) = 10.0 — capped at maximum
module DateHelper =
// => All functions relate to date operations
let isWeekend (date: System.DateTime) : bool =
date.DayOfWeek = System.DayOfWeek.Saturday ||
date.DayOfWeek = System.DayOfWeek.Sunday
// => true on weekends
let dayName (date: System.DateTime) : string =
date.DayOfWeek.ToString ()
// => "Monday", "Tuesday", etc.
printfn "%s" (StringFormatter.formatTitle "hello world")
// => HELLO WORLD
printfn "%.1f" (TaxCalculator.calculate 0.1 200.0)
// => 20.0Key Takeaway: Group functions by what they do, not by convenience. A module with high cohesion has one clear job, making it easy to name, test, and locate.
Why It Matters: Low-cohesion modules like Utils.fs grow without bound in large codebases, becoming catchalls that nobody wants to modify but everybody is afraid to split. High cohesion enables true modularity: each module can evolve, be tested, and be replaced independently.
Encapsulation
Example 18: Encapsulation with Private State
Paradigm Note: Hickey (Simple Made Easy) argues OOP objects "complect state, identity, and value". FP encapsulation hides types and constructors (smart constructors, abstract types, opaque modules), not mutable fields. The example below shows the FP-idiomatic form; for the OOP private-field framing see the sibling tutorial.
Encapsulation means controlling access to internal state so that external code cannot put the system into an inconsistent state. In FP, encapsulation is achieved through opaque types and smart constructors, combined with immutable records that derive computed values on demand rather than caching them in parallel mutable fields.
Poor encapsulation:
// => POOR ENCAPSULATION: mutable public record invites inconsistent state
type PoorTemperature = {
mutable Celsius: float
mutable Fahrenheit: float
// => two parallel fields — if Celsius changes, Fahrenheit must be updated manually
}
let poor = { Celsius = 100.0; Fahrenheit = 212.0 }
// => poor : PoorTemperature — initially consistent
poor.Celsius <- 50.0
// => Celsius changed but Fahrenheit is now stale!
printfn "%.1f" poor.Fahrenheit
// => Output: 212.0 (wrong! should be 122.0)Encapsulated — state changes only through a controlled module:
module Temperature =
// => Opaque type: private backing field, no direct mutation
type T = private { Celsius: float }
// => private label: construction requires calling create below
let create (celsius: float) : Result<T, string> =
if celsius < -273.15 then
Error (sprintf "Temperature below absolute zero: %.2f" celsius)
// => physics constraint enforced at construction time
else
Ok { Celsius = celsius }
// => Ok wraps valid temperature — callers must handle Result
let celsius (t: T) : float = t.Celsius
// => read-only accessor
let fahrenheit (t: T) : float = t.Celsius * 9.0 / 5.0 + 32.0
// => always computed from Celsius — never stale
// => celsius=100 → fahrenheit=212.0
let kelvin (t: T) : float = t.Celsius + 273.15
// => always derived from single source of truth
let toCelsius (value: float) : Result<T, string> = create value
// => returns a NEW temperature value — immutable pattern
match Temperature.create 100.0 with
| Error e -> printfn "Error: %s" e
| Ok t ->
printfn "Celsius: %.1f" (Temperature.celsius t)
// => Output: Celsius: 100.0
printfn "Fahrenheit: %.1f" (Temperature.fahrenheit t)
// => Output: Fahrenheit: 212.0
printfn "Kelvin: %.2f" (Temperature.kelvin t)
// => Output: Kelvin: 373.15
match Temperature.create -300.0 with
| Error e -> printfn "Error: %s" e
// => Output: Error: Temperature below absolute zero: -300.00
| Ok _ -> ()Key Takeaway: Use a module with private types and create to enforce invariants. Derived values should always be computed from a single source of truth rather than cached in parallel fields.
Why It Matters: Every public mutable field in a record is a potential consistency bug. Opaque types and immutable-by-default values — a pattern available in F#, Haskell, Clojure, and TypeScript — make it structurally harder to create stale state. Systems where state changes are controlled and validated at the module boundary make invariant violations physically impossible.
Composition Over Inheritance
Example 19: Preferring Composition
Paradigm Note: "Composition over inheritance" is an intra-OOP debate. In FP there is no inheritance to compose against — function composition is the default and only mechanism. Seemann (Design patterns across paradigms): "you can keep composing functions just as you keep composing classes". See the OOP framing.
Composition over inheritance means building complex behavior by combining simple, focused functions or values rather than deep type hierarchies. In FP, sum types and record-of-functions composition replace inheritance hierarchies — each variant carries exactly the data it needs, and behaviors are assembled at the call site from small reusable functions.
graph TD
Bird["Bird concept"]
Fly["fly behavior"]
Swim["swim behavior"]
Eagle["eagle"]
Duck["duck"]
Penguin["penguin"]
Eagle -- "has" --> Fly
Duck -- "has" --> Fly
Duck -- "has" --> Swim
Penguin -- "has" --> Swim
style Bird fill:#CA9161,stroke:#000,color:#fff
style Fly fill:#0173B2,stroke:#000,color:#fff
style Swim fill:#029E73,stroke:#000,color:#fff
style Eagle fill:#DE8F05,stroke:#000,color:#fff
style Duck fill:#DE8F05,stroke:#000,color:#fff
style Penguin fill:#DE8F05,stroke:#000,color:#fff
Function composition approach — flexible assembly of behaviors:
// => BEHAVIOR FUNCTIONS — small, focused, reusable
let canFly () : string = "Flapping wings"
// => standard flying behavior — any bird that flies uses this
let cannotFly () : string = "Cannot fly"
// => used by non-flying birds — honest about capability
let canSwim () : string = "Swimming"
// => used by aquatic birds — any bird that swims uses this
// => BIRD RECORDS — compose only the behaviors each bird actually has
type Eagle = { Fly: unit -> string }
// => eagles can only fly — no swim field
type Duck = { Fly: unit -> string; Swim: unit -> string }
// => ducks can fly AND swim — record composition
type Penguin = { Fly: unit -> string; Swim: unit -> string }
// => penguins have both fields — but Fly uses cannotFly behavior
// => Construct each bird by assembling the appropriate behaviors
let eagle = { Eagle.Fly = canFly }
// => eagle.Fly = canFly — flying is the only capability
let duck = { Duck.Fly = canFly; Swim = canSwim }
// => duck.Fly = canFly, duck.Swim = canSwim — both capabilities
let penguin = { Penguin.Fly = cannotFly; Swim = canSwim }
// => penguin.Fly = cannotFly — cannot fly; uses honest behavior
printfn "%s" (eagle.Fly ())
// => Output: Flapping wings
printfn "%s" (duck.Fly ())
// => Output: Flapping wings
printfn "%s" (duck.Swim ())
// => Output: Swimming
printfn "%s" (penguin.Fly ())
// => Output: Cannot fly (no exception thrown — contract honored)
printfn "%s" (penguin.Swim ())
// => Output: SwimmingKey Takeaway: Model capabilities with composable behavior functions. Compose a type from only the behaviors it actually has — no unused fields, no exceptions thrown from "inherited" operations.
Why It Matters: Deep inheritance hierarchies are a primary driver of architectural rigidity. In FP, data types do not inherit behavior — the composition approach is not just preferred, it is the only option. This makes LSP violations structurally impossible: a Penguin type without a Fly field simply cannot be passed to a function that expects a flying capability.
Example 20: Mixin vs. Composition
Mixins add behavior to a type without deep inheritance. In FP, mixin-like behavior is expressed through utility functions that operate on any compatible data shape, while explicit composition is done through records of functions (or maps of functions). Understanding when to use each is a foundational architectural decision.
Module-function approach (F# equivalent of mixins) — reuses capability without inheritance:
// => MODULE FUNCTION MIXIN: operates on any record with a name field
// => In OOP this would be a mixin — in F# it is a module function that uses structural typing
module Serializable =
let toJson (fields: (string * string) list) : string =
fields
|> List.map (fun (k, v) -> sprintf "\"%s\": \"%s\"" k v)
// => each field formatted as "key": "value"
|> String.concat ", "
// => joined with comma-space
|> sprintf "{ %s }"
// => wrapped in braces
// => Output: { "name": "Laptop", "price": "1200.0" }
type Product = { Name: string; Price: float }
// => Product record — no mixin needed, module function works on any data
let laptop = { Name = "Laptop"; Price = 1200.0 }
let productJson = Serializable.toJson [ "name", laptop.Name; "price", string laptop.Price ]
// => Serializable.toJson applied to product fields — no inheritance required
printfn "%s" productJson
// => Output: { "name": "Laptop", "price": "1200.0" }Explicit composition — behaviors are injected as record-of-functions fields:
// => EXPLICIT COMPOSITION: behaviors are record fields — testable and swappable
type Serializer = { Serialize: (string * string) list -> string }
// => Serializer capability: takes a list of key-value pairs, returns a string
type Clock = { Now: unit -> System.DateTimeOffset }
// => Clock capability: returns current timestamp — injectable for deterministic tests
type Order = {
Id: int
Amount: float
CreatedAt: System.DateTimeOffset
// => fields set at construction time via injected Clock
Serializer: Serializer
// => injected Serializer — can be swapped for a test double
}
let makeOrder (id: int) (amount: float) (clock: Clock) (ser: Serializer) : Order = {
Id = id
Amount = amount
CreatedAt = clock.Now ()
// => creation time captured via injected clock — deterministic in tests
Serializer = ser
// => serializer captured in the record
}
let toJson (order: Order) : string =
order.Serializer.Serialize [
"id", string order.Id
"amount", string order.Amount
"createdAt", order.CreatedAt.ToString "o"
// => ISO 8601 timestamp
]
// => delegates to injected Serializer
let realClock : Clock = { Now = fun () -> System.DateTimeOffset.UtcNow }
let realSer : Serializer = {
Serialize = fun fields ->
fields
|> List.map (fun (k,v) -> sprintf "\"%s\": \"%s\"" k v)
|> String.concat ", "
|> sprintf "{ %s }"
}
let order = makeOrder 1 99.9 realClock realSer
printfn "%s" (toJson order)
// => Output: { "id": "1", "amount": "99.9", "createdAt": "2026-05-17T..." }
// => In tests: inject a fixed-time clock — fully deterministic outputKey Takeaway: Use module functions for optional, non-configurable capabilities shared across types. Use explicit record-of-functions composition when the behavior needs to be swapped, tested, or configured independently.
Why It Matters: In FP, the equivalent of "mixin hell" is a long chain of utility functions that all share global state or are hard to test in isolation. Explicit composition via record-of-functions (or map-of-functions) fields makes every dependency visible in the signature, enabling clean injection and deterministic testing.
Repository Pattern
Example 21: Repository Pattern Basics
Paradigm Note: Repository is a DDD pattern (Eric Evans) framed around mutable object graphs. In FP the equivalent is a record of effectful functions or a typeclass — Wlaschin (Domain Modeling Made Functional) treats data access as an effect boundary. See the OOP framing.
The Repository pattern abstracts the data access layer behind a collection-like interface. In FP, a repository is expressed as a record-of-functions (or map-of-functions) — find, save, findAll — that can be satisfied by either an in-memory implementation or a database driver. The business layer sees only the interface, never the implementation.
graph LR
Service["Business Logic<br/>(pure functions)"]
Repo["Repository<br/>(record of functions)"]
ImplA["inMemoryRepo<br/>(tests)"]
ImplB["dbRepo<br/>(production)"]
Service -->|depends on| Repo
Repo -->|implemented by| ImplA
Repo -->|implemented by| ImplB
style Service fill:#0173B2,stroke:#000,color:#fff
style Repo fill:#DE8F05,stroke:#000,color:#fff
style ImplA fill:#029E73,stroke:#000,color:#fff
style ImplB fill:#CC78BC,stroke:#000,color:#fff
// => DOMAIN TYPE
type Product = { Id: int; Name: string; Price: float }
// => simple record representing a product in the catalog
// => REPOSITORY INTERFACE as a record of functions
type ProductRepository = {
FindById: int -> Product option
// => given an id, returns Some product or None
Save: Product -> unit
// => persists a product — unit return means side effect only
FindAll: unit -> Product list
// => returns all products in the store
}
// => IN-MEMORY IMPLEMENTATION — for tests and fast prototyping
let makeInMemoryRepo () : ProductRepository =
// => mutable local dict as the backing store — isolated per call
let store = System.Collections.Generic.Dictionary<int, Product>()
// => Dictionary: id → Product
{
FindById = fun id ->
match store.TryGetValue id with
| true, product -> Some product
// => found: return Some product
| _ -> None
// => not found: return None
Save = fun product ->
store.[product.Id] <- product
// => upsert: add or overwrite by id
FindAll = fun () ->
store.Values |> Seq.toList
// => snapshot of all values as an F# list
}
// => SERVICE — accepts any ProductRepository — decoupled from specific implementation
let addProduct (repo: ProductRepository) (id: int) (name: string) (price: float) : Product =
let product = { Id = id; Name = name; Price = price }
repo.Save product
// => delegates persistence to repository
product
// => returns the created product
let getProduct (repo: ProductRepository) (id: int) : Product option =
repo.FindById id
// => delegates retrieval to repository — business layer never knows about storage
// => USAGE: inject the in-memory repo for this demo
let repo = makeInMemoryRepo ()
let p = addProduct repo 1 "Laptop" 1200.0
printfn "%A" p
// => Output: { Id = 1; Name = "Laptop"; Price = 1200.0 }
printfn "%A" (getProduct repo 1)
// => Output: Some { Id = 1; Name = "Laptop"; Price = 1200.0 }
printfn "%A" (getProduct repo 99)
// => Output: NoneKey Takeaway: Define a record of functions for persistence and inject the implementation. The business layer never imports a database driver.
Why It Matters: The repository pattern is why FP business functions can be tested at full speed without a live database. Because a repository is just a record-of-functions (or map-of-functions), a test implementation is a literal value — no mocking framework, no stubs. Swapping to a real database means creating a different implementation and injecting it at the composition root.
Example 22: Repository with Query Methods
Paradigm Note: Same DDD origin as Example 21. FP form: query becomes an effectful function returning
Result<T,_>/Async<T>/Maybe<T>— no mutable backing collection. See the OOP framing.
Real repositories go beyond simple CRUD. They expose domain-meaningful query functions that express business questions as named fields rather than raw queries embedded in business logic. Whether represented as a typed record-of-functions (F#, Haskell, TypeScript) or a plain map of keyword-to-function entries (Clojure), the business layer calls named operations and never sees storage details.
// => DOMAIN TYPE
type Order = { Id: int; CustomerId: int; Total: float; Status: string }
// => record: Id, CustomerId, Total, Status — all required
// => DOMAIN-SPECIFIC REPOSITORY — queries named for business intent
// [Clojure: plain map {:save fn :find-by-id fn ...} — no type declaration; shape enforced by convention]
type OrderRepository = {
Save: Order -> unit
// => persist or update an order
FindById: int -> Order option
// => lookup by primary key
FindByCustomerId: int -> Order list
// => named for business question: "which orders belong to this customer?"
FindPendingAbove: float -> Order list
// => named for business question: "which pending orders exceed this threshold?"
}
// => IN-MEMORY IMPLEMENTATION — satisfies all query functions
let makeInMemoryOrderRepo () : OrderRepository =
let store = System.Collections.Generic.Dictionary<int, Order>()
{
Save = fun order ->
store.[order.Id] <- order
// => upsert by id
FindById = fun id ->
match store.TryGetValue id with
| true, o -> Some o
| _ -> None
FindByCustomerId = fun customerId ->
store.Values |> Seq.filter (fun o -> o.CustomerId = customerId) |> Seq.toList
// => filters all orders where CustomerId matches
FindPendingAbove = fun threshold ->
store.Values
|> Seq.filter (fun o -> o.Status = "pending" && o.Total > threshold)
// => keeps only pending orders with total above threshold
|> Seq.toList
}
// => USAGE: business service uses named queries — reads like a business document
let repo = makeInMemoryOrderRepo ()
repo.Save { Id = 1; CustomerId = 10; Total = 150.0; Status = "pending" }
repo.Save { Id = 2; CustomerId = 10; Total = 800.0; Status = "pending" }
repo.Save { Id = 3; CustomerId = 20; Total = 200.0; Status = "shipped" }
printfn "Customer 10 orders: %d" (repo.FindByCustomerId 10 |> List.length)
// => Output: Customer 10 orders: 2 (orders 1 and 2)
printfn "Pending above 500: %d" (repo.FindPendingAbove 500.0 |> List.length)
// => Output: Pending above 500: 1 (only order 2: 800 > 500)Key Takeaway: Repository function fields should read as business questions. Every query field should have a domain-meaningful name.
Why It Matters: Named query functions make the repository the living documentation of data access patterns. When a developer reads FindPendingAbove threshold, they understand the business intent immediately. Generic Execute(sql) functions leak persistence technology into the service layer and make it impossible to swap storage backends without rewriting business logic.
Service Layer Pattern
Example 23: Service Layer Coordinates Use Cases
The Service Layer pattern centralizes application use cases in dedicated functions. A use case (like "place an order") typically involves multiple domain types and repositories. The service layer is a set of pure functions that coordinate domain logic and repository calls — operating on plain values and returning structured results that make both success and failure explicit.
// ============================================================
// DOMAIN TYPES AND PURE RULES
// ============================================================
type Customer = { Id: int; Credit: float }
// => credit: available spending capacity
type Product = { Id: int; Name: string; Price: float; Stock: int }
// => stock: number of units available
let canAfford (amount: float) (customer: Customer) : bool =
customer.Credit >= amount
// => pure function: true if credit covers amount
let isAvailable (product: Product) : bool =
product.Stock > 0
// => pure function: true when stock is positive
let deductCredit (amount: float) (customer: Customer) : Customer =
{ customer with Credit = customer.Credit - amount }
// => returns new customer with reduced credit — immutable update
let reserveStock (product: Product) : Product =
{ product with Stock = product.Stock - 1 }
// => returns new product with decremented stock — immutable update
// ============================================================
// SERVICE LAYER — orchestrates the "place order" use case
// ============================================================
// [Clojure: tagged map {:status :success :message ...} — data-first; no compile-time exhaustiveness]
type PlaceOrderResult =
| Success of message: string * Customer * Product
// => carries updated entities alongside the message
| Failure of reason: string
// => carries the reason for failure
let placeOrder (customer: Customer) (product: Product) : PlaceOrderResult =
// => STEP 1: apply business rules via pure domain functions
if not (isAvailable product) then
Failure (sprintf "'%s' is out of stock" product.Name)
elif not (canAfford product.Price customer) then
Failure (sprintf "Insufficient credit for '%s' ($%.2f)" product.Name product.Price)
else
// => STEP 2: compute state changes — immutable updates, no mutation
let updatedCustomer = deductCredit product.Price customer
// => updatedCustomer.Credit = customer.Credit - product.Price
let updatedProduct = reserveStock product
// => updatedProduct.Stock = product.Stock - 1
let message = sprintf "Order placed: %s for customer %d" product.Name customer.Id
// => Output: "Order placed: Laptop for customer 1"
Success (message, updatedCustomer, updatedProduct)
let customers = Map.ofList [ 1, { Id = 1; Credit = 2000.0 }; 2, { Id = 2; Credit = 100.0 } ]
let products = Map.ofList [
10, { Id = 10; Name = "Laptop"; Price = 1200.0; Stock = 2 }
11, { Id = 11; Name = "Headphones"; Price = 150.0; Stock = 0 }
]
let print result =
match result with
| Success (msg, _, _) -> printfn "%s" msg
| Failure reason -> printfn "Failed: %s" reason
print (placeOrder customers.[1] products.[10])
// => Order placed: Laptop for customer 1
print (placeOrder customers.[2] products.[10])
// => Failed: Insufficient credit for 'Laptop' ($1200.00)
print (placeOrder customers.[1] products.[11])
// => Failed: 'Headphones' is out of stockKey Takeaway: The service layer owns the sequence of steps for a use case. Domain functions own their own rules. In F#, the service layer is a composition of pure functions — no class required.
Why It Matters: Without a service layer, use case logic scatters into handler functions and domain types — creating duplicate sequences with subtle differences that diverge over time. The FP service layer is particularly clean because it composes pure functions: every step is testable independently.
Example 24: Service Layer with Error Handling
A mature service layer handles errors explicitly, returning structured result values rather than leaking exceptions to the presentation layer. A sum type such as Result/Either (F#, Haskell, TypeScript) or a tagged result map with a :status key (Clojure) encodes every failure mode in the function signature so callers cannot silently ignore it.
// => RESULT TYPE: represents success or failure without exceptions
// => 'T is the success payload, string is the error description
// => F# Result<'T, 'E> is built-in — no library needed
// [Clojure: tagged map {:status :ok/:error ...} — open data; no compile-time exhaustiveness]
// => DOMAIN
let mutable inventory : Map<string, int> =
Map.ofList [ "Widget", 10; "Gadget", 0 ]
// => mutable for demonstration; in production use repository pattern
// => SERVICE with explicit Result return type
let reserve (item: string) (quantity: int) : Result<{| item: string; reserved: int |}, string> =
match Map.tryFind item inventory with
| None ->
Error (sprintf "Item '%s' does not exist" item)
// => explicit failure: item not in catalog
| Some stock when stock < quantity ->
Error (sprintf "Insufficient stock for '%s': have %d, requested %d" item stock quantity)
// => explicit failure: not enough stock
| Some _ ->
inventory <- Map.add item (inventory.[item] - quantity) inventory
// => decrements stock — side effect in this demo
Ok {| item = item; reserved = quantity |}
// => explicit success with anonymous record payload
// => HANDLER: pattern matches on Result — error path is always visible
let handleReserve (item: string) (quantity: int) : string =
match reserve item quantity with
// => result is either Ok payload or Error message
| Ok data ->
sprintf "Reserved %dx %s" data.reserved data.item
// => Output: "Reserved 3x Widget"
| Error reason ->
sprintf "Reservation failed: %s" reason
// => Output: "Reservation failed: Insufficient stock for 'Gadget': have 0, requested 1"
printfn "%s" (handleReserve "Widget" 3)
// => Reserved 3x Widget
printfn "%s" (handleReserve "Gadget" 1)
// => Reservation failed: Insufficient stock for 'Gadget': have 0, requested 1
printfn "%s" (handleReserve "Unknown" 1)
// => Reservation failed: Item 'Unknown' does not existKey Takeaway: Returning Result types forces callers to handle both success and failure paths. Unhandled errors become a compile-time concern rather than a production incident.
Why It Matters: Explicit result types — Result/Either in F# and Haskell, tagged union types in TypeScript, tagged maps in Clojure — enforce explicit error handling at the language level, the same philosophy as Rust's Result<T, E> and Go's (value, error). Pattern matching and exhaustiveness checks make "fire and forget" error handling visible before it reaches production.
DTO Pattern
Example 25: Data Transfer Objects
A Data Transfer Object (DTO) is a simple container for carrying data between layers or across service boundaries. DTOs have no business logic — they are pure data carriers. A DTO holds only the fields appropriate for the boundary it crosses; sensitive or internal fields are physically absent from the type. Using DTOs decouples the internal domain model from the external representation.
graph LR
Ext["External Client<br/>(API / CLI)"]
DTO["CreateUserRequest<br/>(input record)"]
Svc["Service Layer<br/>(pure functions)"]
Domain["User domain record<br/>(internal)"]
RespDTO["UserResponse<br/>(output record)"]
Ext -->|sends| DTO
DTO -->|mapped to| Domain
Domain -->|processed by| Svc
Svc -->|mapped to| RespDTO
RespDTO -->|returned to| Ext
style Ext fill:#CA9161,stroke:#000,color:#fff
style DTO fill:#0173B2,stroke:#000,color:#fff
style Svc fill:#DE8F05,stroke:#000,color:#fff
style Domain fill:#029E73,stroke:#000,color:#fff
style RespDTO fill:#CC78BC,stroke:#000,color:#fff
// => REQUEST DTO — represents data coming IN from the external world
// [Clojure: plain map {:name "Alice" :email "..."} — no type; boundary enforced by select-keys]
type CreateUserRequest = {
Name: string
// => raw string from HTTP request body
Email: string
// => raw string from HTTP request body
// => no domain logic: just carries data across the boundary
}
// => RESPONSE DTO — represents data going OUT to the external world
type UserResponse = {
UserId: int
// => safe to expose externally
Name: string
Email: string
// => notably MISSING: PasswordHash, InternalFlags, AuditTrail
// => DTOs shape what external clients can see
}
// => DOMAIN RECORD — internal representation with full context
type User = {
UserId: int
Name: string
Email: string
PasswordHash: string
// => internal only — never in DTO
IsAdmin: bool
// => internal only — never in DTO
}
// => SERVICE FUNCTIONS — map between DTOs and domain records
let mutable private users : Map<int, User> = Map.empty
let mutable private nextId = 1
let createUser (request: CreateUserRequest) : UserResponse =
// => MAP: DTO → Domain Record
let user = {
UserId = nextId
Name = request.Name
// => copied from DTO
Email = request.Email
// => copied from DTO
PasswordHash = "hashed_secret"
// => generated internally, NOT in DTO
IsAdmin = false
// => internal default, NOT in DTO
}
users <- Map.add nextId user users
nextId <- nextId + 1
// => MAP: Domain Record → Response DTO
{ UserId = user.UserId; Name = user.Name; Email = user.Email }
// => PasswordHash and IsAdmin intentionally EXCLUDED from response
let request = { Name = "Alice"; Email = "alice@example.com" }
let response = createUser request
printfn "%A" response
// => Output: { UserId = 1; Name = "Alice"; Email = "alice@example.com" }
// => PasswordHash is NOT in the response — protected by DTO boundaryKey Takeaway: DTOs are the shape of data at a boundary — they protect internal domain structure from external exposure and decouple serialization from business logic.
Why It Matters: Every major data breach involving accidental field exposure (password hashes returned in API responses, admin flags visible to users) is a failure to use DTOs. When the response type has no PasswordHash field, it is physically impossible to include it in a response — a structural guarantee that typed FP languages enforce automatically.
Example 26: DTO Validation
DTOs are the right place to validate external input before it enters the domain layer. A smart constructor validates every field before constructing the DTO and returns a result type on failure — accumulating all errors rather than stopping at the first. A successfully constructed DTO is therefore guaranteed valid; the domain layer never needs to re-validate the same fields.
// => VALIDATION ERRORS as a discriminated union
// [Clojure: vector of keyword/string error descriptors — open; no exhaustiveness check]
type ValidationError =
| EmptyName
// => name field was blank
| NegativePrice of given: float
// => price must be positive
| NegativeStock of given: int
// => stock cannot be negative
// => REQUEST DTO with smart constructor
module CreateProductRequest =
type T = private {
Name: string
Price: float
Stock: int
// => private constructor: valid T is always constructed via create below
}
let create (rawName: string) (rawPrice: float) (rawStock: int)
: Result<T, ValidationError list> =
// => Collect all errors rather than failing on the first
let errors = [
if rawName.Trim () = "" then yield EmptyName
// => invalid name rejected at DTO boundary
if rawPrice <= 0.0 then yield NegativePrice rawPrice
// => negative price rejected at DTO boundary
if rawStock < 0 then yield NegativeStock rawStock
// => negative stock rejected at DTO boundary
]
if List.isEmpty errors then
Ok { Name = rawName.Trim (); Price = rawPrice; Stock = rawStock }
// => Ok wraps valid DTO — all fields guaranteed clean
else
Error errors
// => Error carries the full list of validation failures
let name (t: T) = t.Name
let price (t: T) = t.Price
let stock (t: T) = t.Stock
// => SERVICE receives only validated DTOs — no re-validation needed
let createProduct (rawName: string) (rawPrice: float) (rawStock: int) : string =
match CreateProductRequest.create rawName rawPrice rawStock with
| Ok req ->
sprintf "Product created: %s at $%.2f (stock: %d)"
(CreateProductRequest.name req)
(CreateProductRequest.price req)
(CreateProductRequest.stock req)
// => Output: "Product created: Widget at $9.99 (stock: 100)"
| Error errs ->
let messages = errs |> List.map (function
| EmptyName -> "name must be non-empty"
| NegativePrice p -> sprintf "price must be positive, got %.2f" p
| NegativeStock s -> sprintf "stock must be non-negative, got %d" s)
sprintf "Validation failed: %s" (String.concat "; " messages)
// => Output: "Validation failed: name must be non-empty"
printfn "%s" (createProduct "Widget" 9.99 100)
// => Product created: Widget at $9.99 (stock: 100)
printfn "%s" (createProduct "" 9.99 100)
// => Validation failed: name must be non-empty
printfn "%s" (createProduct "Widget" -5.0 100)
// => Validation failed: price must be positive, got -5.00Key Takeaway: Validate external input in the DTO smart constructor so that a successfully constructed DTO is guaranteed valid. Business logic should never need to re-validate the same fields.
Why It Matters: Validation scattered across handler functions, service functions, and domain modules produces inconsistent enforcement. In F#, the Result type from a DTO smart constructor forces every caller to explicitly handle the validation failure path. This prevents an entire class of injection and data corruption attacks.
Putting It All Together
Example 27: Small Layered Application
This example combines the patterns introduced in Examples 1-26 into a minimal but realistic layered application: a product catalog with separation of concerns, repository, service, and DTO layers all working together. F#, Clojure, TypeScript, and Haskell each express the same four-layer structure — typed records and pipelines, plain maps and threading macros, interfaces and readonly types, and algebraic data types with IO — without changing the architecture.
// ============================================================
// DTOs — data shapes at the boundary
// ============================================================
// [Clojure: plain maps {:name "..." :price ...} — boundary enforced by select-keys]
type AddProductRequest = { Name: string; Price: float }
// => input DTO: carries validated data from caller to service
type ProductSummary = { ProductId: int; Name: string; Price: float }
// => output DTO: carries safe data from service to caller
// ============================================================
// DOMAIN — internal representation
// ============================================================
type Product = {
ProductId: int
Name: string
Price: float
IsFeatured: bool
// => internal flag — intentionally absent from ProductSummary DTO
}
// ============================================================
// REPOSITORY — data access abstraction (record of functions)
// ============================================================
// [Clojure: plain map {:save fn :find-all fn :next-id fn} — no type declaration]
type ProductRepo = {
Save: Product -> unit
FindAll: unit -> Product list
NextId: unit -> int
}
let makeRepo () : ProductRepo =
let store = System.Collections.Generic.Dictionary<int, Product>()
let counter = ref 1
{
Save = fun p -> store.[p.ProductId] <- p
// => upsert by ProductId
FindAll = fun () -> store.Values |> Seq.toList
// => returns all products as a list
NextId = fun () ->
let id = !counter
counter := id + 1
id
// => returns current counter then increments — auto-increment pattern
}
// ============================================================
// SERVICE — coordinates use cases
// ============================================================
let addProduct (repo: ProductRepo) (req: AddProductRequest) : Result<ProductSummary, string> =
if req.Price <= 0.0 then
Error "Price must be positive"
// => business rule: no free or negative-priced products
else
let product = {
ProductId = repo.NextId ()
Name = req.Name
Price = req.Price
IsFeatured = false
// => defaults; caller cannot set this via DTO
}
repo.Save product
// => delegates persistence to repo
Ok { ProductId = product.ProductId; Name = product.Name; Price = product.Price }
// => maps domain → output DTO; IsFeatured excluded
let listAll (repo: ProductRepo) : ProductSummary list =
repo.FindAll ()
// => fetches from repo
|> List.map (fun p -> { ProductId = p.ProductId; Name = p.Name; Price = p.Price })
// => maps each domain record to output DTO; IsFeatured excluded
// ============================================================
// PRESENTATION — wires and calls
// ============================================================
let repo = makeRepo ()
let printResult label result =
match result with
| Ok summary -> printfn "%s OK: %d. %s $%.2f" label summary.ProductId summary.Name summary.Price
| Error e -> printfn "%s Error: %s" label e
printResult "Add Laptop" (addProduct repo { Name = "Laptop"; Price = 1200.0 })
// => Add Laptop OK: 1. Laptop $1200.00
printResult "Add Mouse" (addProduct repo { Name = "Mouse"; Price = 25.0 })
// => Add Mouse OK: 2. Mouse $25.00
printResult "Add Bad" (addProduct repo { Name = "Free"; Price = 0.0 })
// => Add Bad Error: Price must be positive
listAll repo
|> List.iter (fun s -> printfn " %d. %s: $%.2f" s.ProductId s.Name s.Price)
// => 1. Laptop: $1200.00
// => 2. Mouse: $25.00Key Takeaway: Each layer has a clear job: DTOs carry data at boundaries, the domain holds types and rules, the repository manages storage, and the service coordinates use cases. The presentation layer does nothing but wire and call.
Why It Matters: This four-layer structure reliably scales from a two-person project to a large engineering team because each layer can be replaced, tested, and scaled independently. In FP, the entire structure is just values, modules, and pure functions — no framework required. The lack of inheritance means each boundary is enforced by function signatures or structural types, not by convention.
Example 28: Recognizing Architecture Smells
Architecture smells are patterns in code structure that signal design problems. Recognizing them early prevents costly refactoring later. Both F# and Clojure are susceptible to the same four common smells — though they manifest with language-specific shapes.
graph TD
A["Architecture Smell"]
B["God Module<br/>Does everything"]
C["Leaking Layers<br/>DB logic in handler"]
D["Anemic Domain<br/>Empty domain types"]
E["Implicit Coupling<br/>Global mutable state"]
A --> B
A --> C
A --> D
A --> E
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#DE8F05,stroke:#000,color:#fff
style C fill:#DE8F05,stroke:#000,color:#fff
style D fill:#029E73,stroke:#000,color:#fff
style E fill:#CC78BC,stroke:#000,color:#fff
Smell 1: God Module — one namespace knows everything:
// => GOD MODULE: one module handles users, orders, payments, and reports
// => Signs: module has functions across unrelated domains
module ApplicationManagerBad =
let createUser (name: string) : unit = ()
// => user concern
let placeOrder (userId: int) : unit = ()
// => order concern
let chargeCard (amount: float): unit = ()
// => payment concern — unrelated to users and orders
let generateReport () : string = ""
// => reporting concern — unrelated to the other three
// => Every new feature goes here — module grows without bound
// => Fix: split into UserModule, OrderModule, PaymentModule, ReportModuleSmell 2: Layer leakage — data access in the handler:
// => LAYER LEAK: HTTP handler directly queries a database connection
// => Handler should never see SQL or database drivers
let handleGetUserBad (userId: int) : string =
// => data access in the presentation layer — layer violation
let fakeDb = Map.ofList [ 1, "Alice"; 2, "Bob" ]
// => simulates a direct DB call inside a handler — wrong layer
match Map.tryFind userId fakeDb with
| Some name -> sprintf "User: %s" name
| None -> "Not found"
// => Fix: extract to UserRepository.FindById and call from service layerSmell 3: Anemic domain — domain types with no behavior:
// => ANEMIC DOMAIN: Order is just a data bag — all logic is in the service
type AnemicOrder = { Id: int; Total: float; Status: string }
// => No functions, no rules — just fields
// => All business logic forced into service functions, making them god functions
// => FIX: move behavior INTO the domain by pairing types with co-located functions
type RichOrder = { Id: int; Total: float; Status: string }
let canCancelOrder (order: RichOrder) : bool =
order.Status = "pending"
// => business rule lives next to the type — service calls canCancelOrder, not repeating the rule
// => co-location is the FP equivalent of OOP's "behavior on the class"Smell 4: Implicit coupling via global mutable state:
// => GLOBAL STATE: any function can read or write this — invisible coupling
let mutable private currentUser : string option = None
// => global mutable — execution order now matters everywhere
let login (name: string) : unit =
currentUser <- Some name
// => mutates shared global — any concurrent call corrupts state
let getCurrentUser () : string option =
currentUser
// => any function can read this anytime — invisible dependency
// => Fix: pass user as a parameter or return it from the login function
let loginPure (name: string) : string = name
// => returns the logged-in user instead of mutating global state
// => callers thread the user explicitly — no hidden state, no concurrency hazardKey Takeaway: God modules, layer leakage, anemic domains, and global mutable state are the four most common beginner architecture smells. Recognizing them early is as important as knowing the correct patterns.
Why It Matters: Architecture smells compound silently: a god module at 200 lines becomes unmaintainable at 2,000. FP languages nudge developers away from global mutable state and toward pure functions — F# requires the mutable keyword, Haskell quarantines effects in IO, and Clojure wraps mutation in atoms. But module-level discipline still requires deliberate attention — the type system does not prevent putting all functions in one file. The patterns in Examples 1-27 exist precisely to prevent these smells from taking root by establishing clear, stable boundaries before the codebase grows past the point where restructuring becomes prohibitively expensive.
Last updated May 16, 2026