Pure Core / Imperative Shell
Separate pure business logic from side effects, pushing I/O and mutations to the system boundaries.
Pure Core / Imperative Shell
Master the Pure Core / Imperative Shell architecture with free flashcards and spaced repetition practice. This lesson covers isolating side effects, designing pure functional cores, and implementing imperative shellsโessential concepts for building maintainable, testable F# applications.
Welcome
๐ป Building real-world applications requires dealing with messy realities: databases, file systems, networks, user input, and system clocks. But functional programming thrives on pure functionsโfunctions that are predictable, testable, and free from side effects. How do we reconcile these seemingly opposing forces?
The Pure Core / Imperative Shell pattern (also called Functional Core / Imperative Shell) provides an elegant solution. This architectural pattern separates your application into two distinct layers:
- ๐ข Pure Core: Contains all business logic using pure functions with no side effects
- ๐ด Imperative Shell: Handles all I/O and side effects, delegating decisions to the pure core
This separation creates code that's easier to test, reason about, and refactor. Let's explore how to architect F# applications using this powerful pattern.
Core Concepts
What Are Side Effects?
โก A side effect is any operation that interacts with the outside world or maintains hidden state. Side effects make functions unpredictable and harder to test.
Common side effects include:
- ๐ Reading/writing files
- ๐๏ธ Database queries and updates
- ๐ HTTP requests
- โจ๏ธ Reading user input
- ๐จ๏ธ Printing to console
- ๐ Getting current time
- ๐ฒ Generating random numbers
- ๐พ Mutating shared state
Pure functions have none of these characteristics:
| Pure Functions โ | Impure Functions โ |
|---|---|
| Same input โ same output (deterministic) | Same input โ different outputs possible |
| No side effects | Performs I/O or mutates state |
| Easy to test (no mocks needed) | Requires mocks, stubs, or test doubles |
| Easy to reason about | Must understand external context |
| Can be freely refactored | Refactoring may break external dependencies |
The Architecture Pattern
๐๏ธ The Pure Core / Imperative Shell architecture creates a clear boundary between pure and impure code:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ IMPERATIVE SHELL (thin) โ โ โข Reads input from external sources โ โ โข Calls pure core with data โ โ โข Executes decisions made by pure core โ โ โข Handles all I/O and side effects โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค โ PURE CORE (thick) โ โ โข Contains ALL business logic โ โ โข Pure functions only โ โ โข Makes all decisions โ โ โข Returns data/commands (no execution) โ โ โข Easy to test without mocks โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The flow:
User Input โ Shell โ Core โ Shell โ External World
โฌ โฌ โฌ โฌ โฌ
"John" Convert Process Execute Database
to types logic commands updated
Design Principles
1. Push I/O to the boundaries ๐
Perform all side effects at the edges of your application. The core should receive plain data and return plain data (or commands to execute).
2. Make the shell thin, the core thick ๐
Most of your code should live in the pure core. The shell should be a thin orchestration layer that delegates all decisions to the core.
3. Return decisions, don't execute them ๐
Instead of the core performing I/O, it returns descriptions of what to do (commands, events, or data structures). The shell executes these instructions.
4. Test the core without mocks ๐งช
Because the core is pure, you can test it with simple input/output assertionsโno mocking frameworks needed.
Dependency Injection vs. This Pattern
๐ค Traditional OOP approaches inject dependencies (interfaces) into business logic. This pattern takes a different approach:
| Dependency Injection | Pure Core / Imperative Shell |
|---|---|
| Business logic calls interfaces | Business logic is pure functions |
| Requires mocking for tests | No mocks neededโpure functions |
| Logic interleaved with I/O | Logic completely separated from I/O |
| Test verifies interactions | Test verifies outputs |
๐ก Tip: You can use both patterns togetherโdependency injection in the shell layer for external services, pure functions in the core.
Representing Commands
๐ฏ The pure core doesn't perform actionsโit returns commands or decisions for the shell to execute. Common approaches:
Discriminated Unions (Recommended for F#):
type Command =
| SaveUser of User
| SendEmail of email: string * subject: string * body: string
| LogMessage of string
| NoAction
Records with Actions:
type Decision = {
UserToSave: User option
EmailsToSend: Email list
LogMessages: string list
}
Return Tuples:
// (result, commands to execute)
let processOrder order : OrderResult * Command list
When to Use This Pattern
โ Good fit for:
- Applications with complex business logic
- Systems requiring extensive testing
- Domains with frequent requirement changes
- Command-line tools, batch processors
- Business rule engines
โ ๏ธ Less suitable for:
- Simple CRUD applications with minimal logic
- Highly I/O-bound systems with little computation
- Prototypes or throwaway code
Example 1: User Registration System
๐ Let's build a user registration system that validates input, checks business rules, and persists data.
โ Traditional Approach (mixed concerns):
// Business logic mixed with I/O
let registerUser (db: IDatabase) (email: IEmailService) username password =
if String.IsNullOrWhiteSpace(username) then
printfn "Invalid username"
false
elif db.UserExists(username) then
printfn "Username already taken"
false
else
let user = { Username = username; Password = hashPassword password }
db.SaveUser(user)
email.SendWelcomeEmail(user)
printfn "Registration successful"
true
Problems:
- Logic interleaved with I/O (database, email, console)
- Requires mocking to test
- Hard to understand the business rules
- Can't test without side effects
โ Pure Core / Imperative Shell Approach:
// PURE CORE - All business logic, zero side effects
type ValidationError =
| EmptyUsername
| UsernameTaken
| WeakPassword
type RegistrationCommand =
| SaveUser of User
| SendWelcomeEmail of string
type RegistrationResult =
| Success of RegistrationCommand list
| Failure of ValidationError
// Pure function - takes data, returns decision
let validateRegistration (existingUsers: string list) username password =
if String.IsNullOrWhiteSpace(username) then
Failure EmptyUsername
elif List.contains username existingUsers then
Failure UsernameTaken
elif String.length password < 8 then
Failure WeakPassword
else
let user = { Username = username; Password = hashPassword password }
Success [
SaveUser user
SendWelcomeEmail username
]
// IMPERATIVE SHELL - Handles all I/O
let executeRegistration (db: IDatabase) (email: IEmailService) username password =
// Gather data from external sources
let existingUsers = db.GetAllUsernames()
// Call pure core for decision
match validateRegistration existingUsers username password with
| Failure EmptyUsername ->
printfn "Invalid username"
false
| Failure UsernameTaken ->
printfn "Username already taken"
false
| Failure WeakPassword ->
printfn "Password must be at least 8 characters"
false
| Success commands ->
// Execute commands returned by core
commands |> List.iter (fun cmd ->
match cmd with
| SaveUser user -> db.SaveUser(user)
| SendWelcomeEmail username -> email.SendWelcomeEmail(username)
)
printfn "Registration successful"
true
Testing the pure core (no mocks!):
let testUsernameTaken() =
let result = validateRegistration ["alice"; "bob"] "alice" "password123"
result = Failure UsernameTaken
let testSuccessfulRegistration() =
let result = validateRegistration [] "charlie" "secure_pass"
match result with
| Success commands ->
commands.Length = 2 &&
List.exists (function SaveUser _ -> true | _ -> false) commands
| _ -> false
๐ก Notice: The pure core is trivial to testโjust pass in data, check the output. No database setup, no email mocking, no cleanup.
Example 2: Order Processing Pipeline
๐ Let's process an e-commerce order with inventory checks, pricing rules, and payment processing.
Pure Core:
type OrderCommand =
| ReserveInventory of ProductId * Quantity
| ChargePayment of CustomerId * Amount
| SendConfirmation of OrderId
| SendLowInventoryAlert of ProductId
type OrderError =
| InsufficientInventory of ProductId
| InvalidPaymentAmount
type ProcessOrderResult =
| OrderAccepted of OrderCommand list
| OrderRejected of OrderError
// Pure function - all logic, no side effects
let processOrder (inventory: Map<ProductId, int>) order =
let requiredItems = order.Items |> List.map (fun item -> item.ProductId, item.Quantity)
// Check inventory for all items
let insufficientItems =
requiredItems
|> List.filter (fun (productId, qty) ->
match Map.tryFind productId inventory with
| Some stock -> stock < qty
| None -> true
)
match insufficientItems with
| (productId, _) :: _ ->
OrderRejected (InsufficientInventory productId)
| [] ->
let total = calculateTotal order.Items
if total <= 0.0m then
OrderRejected InvalidPaymentAmount
else
let commands = [
yield! requiredItems |> List.map (fun (pid, qty) -> ReserveInventory(pid, qty))
yield ChargePayment(order.CustomerId, total)
yield SendConfirmation(order.OrderId)
// Check if any inventory is low after reservation
yield! requiredItems
|> List.filter (fun (pid, qty) ->
Map.find pid inventory - qty < 10)
|> List.map (fun (pid, _) -> SendLowInventoryAlert pid)
]
OrderAccepted commands
Imperative Shell:
let executeOrder (inventoryDb: IInventoryDb) (payment: IPaymentService) (notifications: INotificationService) order =
// Load data from external sources
let currentInventory =
order.Items
|> List.map (fun item -> item.ProductId, inventoryDb.GetStock(item.ProductId))
|> Map.ofList
// Call pure core
match processOrder currentInventory order with
| OrderRejected error ->
match error with
| InsufficientInventory productId ->
printfn "Product %A out of stock" productId
| InvalidPaymentAmount ->
printfn "Invalid order amount"
Error "Order rejected"
| OrderAccepted commands ->
// Execute each command
try
commands |> List.iter (fun cmd ->
match cmd with
| ReserveInventory (productId, qty) ->
inventoryDb.ReserveStock(productId, qty)
| ChargePayment (customerId, amount) ->
payment.Charge(customerId, amount)
| SendConfirmation orderId ->
notifications.SendOrderConfirmation(orderId)
| SendLowInventoryAlert productId ->
notifications.AlertLowInventory(productId)
)
Ok "Order processed successfully"
with
| ex ->
printfn "Error executing order: %s" ex.Message
Error "Order processing failed"
๐งช Testing complex scenarios:
let testLowInventoryAlert() =
let inventory = Map [ (ProductId 1, 12); (ProductId 2, 50) ]
let order = {
OrderId = OrderId 100
CustomerId = CustomerId 1
Items = [
{ ProductId = ProductId 1; Quantity = 5 } // 12 - 5 = 7 (below threshold)
{ ProductId = ProductId 2; Quantity = 10 } // 50 - 10 = 40 (ok)
]
}
match processOrder inventory order with
| OrderAccepted commands ->
commands |> List.exists (function
| SendLowInventoryAlert (ProductId 1) -> true
| _ -> false)
| _ -> false
Example 3: File Processing Application
๐ Process log files, extract statistics, and generate reportsโa common real-world scenario.
Pure Core:
type LogEntry = {
Timestamp: DateTime
Level: LogLevel
Message: string
}
type LogStatistics = {
TotalEntries: int
ErrorCount: int
WarningCount: int
AverageEntriesPerHour: float
MostCommonErrors: (string * int) list
}
type ReportCommand =
| WriteReport of filename: string * content: string
| SendAlert of message: string
// Pure - analyzes log entries and decides what to do
let analyzeLogEntries (entries: LogEntry list) : LogStatistics * ReportCommand list =
let errorCount = entries |> List.filter (fun e -> e.Level = Error) |> List.length
let warningCount = entries |> List.filter (fun e -> e.Level = Warning) |> List.length
let timeSpan =
if entries.Length > 0 then
let first = entries |> List.minBy (fun e -> e.Timestamp)
let last = entries |> List.maxBy (fun e -> e.Timestamp)
(last.Timestamp - first.Timestamp).TotalHours
else 1.0
let errorMessages =
entries
|> List.filter (fun e -> e.Level = Error)
|> List.countBy (fun e -> e.Message)
|> List.sortByDescending snd
|> List.truncate 5
let stats = {
TotalEntries = entries.Length
ErrorCount = errorCount
WarningCount = warningCount
AverageEntriesPerHour = float entries.Length / timeSpan
MostCommonErrors = errorMessages
}
let reportContent = formatReport stats // Another pure function
let commands = [
yield WriteReport("log-analysis.txt", reportContent)
if errorCount > 100 then
yield SendAlert(sprintf "High error count: %d errors detected" errorCount)
]
(stats, commands)
// Pure helper function
let formatReport (stats: LogStatistics) : string =
sprintf """Log Analysis Report
==================
Total Entries: %d
Errors: %d
Warnings: %d
Avg Entries/Hour: %.2f
Top Errors:
%s
"""
stats.TotalEntries
stats.ErrorCount
stats.WarningCount
stats.AverageEntriesPerHour
(stats.MostCommonErrors
|> List.map (fun (msg, count) -> sprintf " - %s (%d)" msg count)
|> String.concat "\n")
Imperative Shell:
let processLogFile (filePath: string) =
try
// I/O: Read file
let lines = System.IO.File.ReadAllLines(filePath)
// I/O: Parse with error handling
let entries =
lines
|> Array.choose parseLogLine // parseLogLine handles malformed lines
|> Array.toList
printfn "Loaded %d log entries from %s" entries.Length filePath
// Call pure core
let (stats, commands) = analyzeLogEntries entries
// Execute commands
commands |> List.iter (fun cmd ->
match cmd with
| WriteReport (filename, content) ->
System.IO.File.WriteAllText(filename, content)
printfn "Report written to %s" filename
| SendAlert message ->
// I/O: Send notification
NotificationService.send message
printfn "Alert sent: %s" message
)
Ok stats
with
| ex ->
printfn "Error processing log file: %s" ex.Message
Error ex.Message
๐งช Easy testing of business logic:
let testHighErrorAlert() =
let entries = [
for i in 1..150 do
{ Timestamp = DateTime.Now; Level = Error; Message = "Database timeout" }
]
let (_, commands) = analyzeLogEntries entries
commands |> List.exists (function SendAlert _ -> true | _ -> false)
let testErrorStatistics() =
let entries = [
{ Timestamp = DateTime.Now; Level = Error; Message = "Error A" }
{ Timestamp = DateTime.Now; Level = Error; Message = "Error A" }
{ Timestamp = DateTime.Now; Level = Error; Message = "Error B" }
{ Timestamp = DateTime.Now; Level = Warning; Message = "Warning" }
]
let (stats, _) = analyzeLogEntries entries
stats.ErrorCount = 3 &&
stats.WarningCount = 1 &&
stats.MostCommonErrors.[0] = ("Error A", 2)
Example 4: Game State Management
๐ฎ Managing game state is perfect for this patternโcomplex logic that's easy to test.
Pure Core:
type PlayerAction =
| Move of Direction
| Attack of TargetId
| UseItem of ItemId
type GameEffect =
| UpdatePlayerPosition of Position
| DamageEntity of EntityId * Damage
| PlaySound of SoundId
| SpawnParticles of Position
| RemoveItem of ItemId
type GameState = {
Player: Player
Enemies: Enemy list
Items: Item list
}
// Pure - all game rules, deterministic
let applyAction (state: GameState) (action: PlayerAction) : GameState * GameEffect list =
match action with
| Move direction ->
let newPos = calculateNewPosition state.Player.Position direction
if isValidPosition state newPos then
let updatedPlayer = { state.Player with Position = newPos }
let newState = { state with Player = updatedPlayer }
(newState, [ UpdatePlayerPosition newPos ])
else
(state, [ PlaySound SoundId.Bump ])
| Attack targetId ->
match findEnemy state.Enemies targetId with
| Some enemy when isInRange state.Player.Position enemy.Position ->
let damage = calculateDamage state.Player enemy
let effects = [
DamageEntity (targetId, damage)
PlaySound SoundId.Hit
SpawnParticles enemy.Position
]
(state, effects)
| _ ->
(state, [ PlaySound SoundId.Miss ])
| UseItem itemId ->
match List.tryFind (fun i -> i.Id = itemId) state.Items with
| Some item when item.Type = HealthPotion ->
let healedPlayer = { state.Player with Health = min (state.Player.Health + 50) 100 }
let updatedItems = List.filter (fun i -> i.Id <> itemId) state.Items
let newState = { state with Player = healedPlayer; Items = updatedItems }
(newState, [
RemoveItem itemId
PlaySound SoundId.Heal
SpawnParticles state.Player.Position
])
| _ ->
(state, [])
// Pure helper functions
let calculateDamage (attacker: Player) (defender: Enemy) : int =
let baseDamage = attacker.Strength * 2
let defense = defender.Defense
max 1 (baseDamage - defense)
let isInRange (pos1: Position) (pos2: Position) : bool =
let distance = sqrt (pown (pos2.X - pos1.X) 2 + pown (pos2.Y - pos1.Y) 2)
distance <= 3.0
Imperative Shell:
let gameLoop (renderer: IRenderer) (audio: IAudioSystem) =
let mutable currentState = initializeGame()
while not currentState.GameOver do
// I/O: Render current state
renderer.Draw(currentState)
// I/O: Get player input
let input = Input.waitForAction()
let action = parseInput input
// Call pure core
let (newState, effects) = applyAction currentState action
// Execute effects
effects |> List.iter (fun effect ->
match effect with
| UpdatePlayerPosition pos ->
renderer.UpdatePlayerSprite(pos)
| DamageEntity (entityId, damage) ->
renderer.ShowDamageNumber(entityId, damage)
| PlaySound soundId ->
audio.Play(soundId)
| SpawnParticles pos ->
renderer.CreateParticleEffect(pos)
| RemoveItem itemId ->
renderer.RemoveItemFromUI(itemId)
)
currentState <- newState
๐ก Benefits for game development:
- Easy to test game rules without rendering
- Can replay exact game sequences (pass same state + actions)
- Simple to implement save/load (state is just data)
- Easy to add AI (AI just generates actions based on state)
- Network multiplayer simplified (sync state, send actions)
Common Mistakes
โ ๏ธ 1. Leaking side effects into the pure core
โ Wrong:
let processUser user =
printfn "Processing user: %s" user.Name // Side effect!
if user.Age < 18 then
InvalidUser
else
ValidUser user
โ Right:
// Pure core
let validateUser user =
if user.Age < 18 then InvalidUser
else ValidUser user
// Shell handles logging
let processUser user =
printfn "Processing user: %s" user.Name
validateUser user
โ ๏ธ 2. Making the shell too thick
The shell should be a thin orchestration layer. If you find complex logic in the shell, move it to the core.
โ Wrong:
let executeOrder order =
let inventory = db.GetInventory()
// Complex business logic in shell!
if order.Items |> List.forall (fun item ->
Map.containsKey item.ProductId inventory &&
Map.find item.ProductId inventory >= item.Quantity) then
db.SaveOrder(order)
email.SendConfirmation(order.Id)
else
email.SendRejection(order.Id)
โ Right:
// Logic in pure core
let validateOrder inventory order = (* validation logic *)
// Shell just orchestrates
let executeOrder order =
let inventory = db.GetInventory()
match validateOrder inventory order with
| Valid commands -> commands |> List.iter executeCommand
| Invalid -> email.SendRejection(order.Id)
โ ๏ธ 3. Testing the shell instead of the core
Don't write integration tests for everything. Test the pure core with unit tests, test the shell with a few integration tests.
โ Wrong approach:
// Testing through the shell (slow, brittle)
let testUserValidation() =
use db = createTestDatabase()
use email = createMockEmailService()
let result = executeRegistration db email "alice" "pass"
Assert.False(result)
Assert.Equal(0, db.GetUserCount())
โ Right approach:
// Test the pure core directly (fast, simple)
let testUserValidation() =
let result = validateRegistration [] "alice" "pass"
match result with
| Failure WeakPassword -> true
| _ -> false
โ ๏ธ 4. Returning tasks/promises from pure core
The core should return plain data, not async operations.
โ Wrong:
let processOrder order = async {
// Core shouldn't contain async I/O!
let! inventory = getInventoryAsync()
return validateWithInventory inventory order
}
โ Right:
// Pure core - synchronous, no async
let processOrder inventory order =
validateWithInventory inventory order
// Shell handles async
let executeOrder order = async {
let! inventory = getInventoryAsync()
let result = processOrder inventory order // Call pure function
return result
}
โ ๏ธ 5. Over-engineering simple scenarios
Not every function needs this pattern. For simple CRUD operations, direct I/O is fine.
โ Overkill:
// Too much ceremony for simple CRUD
type GetUserCommand = GetUser of UserId
type UserResult = UserFound of User | UserNotFound
let findUser userId = UserFound // No logic!
let executeGetUser cmd =
match cmd with
| GetUser id ->
match findUser id with
| UserFound u -> db.LoadUser(id)
| UserNotFound -> None
โ Appropriate:
// Simple lookup doesn't need the pattern
let getUser userId = db.LoadUser(userId)
๐ก Use this pattern when you have:
- Complex business rules
- Logic that needs extensive testing
- Decisions based on multiple data sources
- Rules that change frequently
Key Takeaways
๐ฏ Core principles:
- Separate concerns: Pure business logic in the core, I/O in the shell
- Make decisions, don't execute: Core returns commands, shell executes them
- Test without mocks: Pure functions are trivial to test with simple assertions
- Push I/O to boundaries: Load all data first, process it purely, execute results
- Keep shell thin: Most code should be in the pure, testable core
๐ Benefits:
- โ Testability: Pure functions need no mocks, stubs, or test doubles
- โ Maintainability: Business logic is isolated and easy to change
- โ Reasoning: Pure functions are predictable and composable
- โ Refactoring: No fear of breaking hidden dependencies
- โ Parallelization: Pure functions can be safely parallelized
โก Quick decision tree:
Does it have complex
business logic?
โ
โโโโโโโโโโโโโดโโโโโโโโโโโโ
โ โ
โโYESโโ โโNOโโโ
โ โ โ โ
โผ โผ โผ โผ
Use Pure Core/ Direct I/O is
Imperative Shell fine
architecture
๐ Quick Reference Card
| Concept | Description |
|---|---|
| Pure Core | Contains all business logic using pure functions with no side effects |
| Imperative Shell | Thin layer that handles all I/O and executes core's decisions |
| Side Effect | Any operation that interacts with external world (I/O, time, randomness) |
| Command | Data structure representing an action to be executed by the shell |
| Flow | Shell โ gather data โ Core โ make decisions โ Shell โ execute |
| Testing | Test core with simple assertions (no mocks), shell with integration tests |
| Design Goal | Thick core (most code), thin shell (minimal orchestration) |
๐ Further Study
- Mark Seemann - Functional Architecture - The Pits of Success - Excellent talk on functional architecture principles
- F# for Fun and Profit - Dependency Injection in FP - Scott Wlaschin's deep dive into managing dependencies functionally
- Boundaries by Gary Bernhardt - Classic talk introducing the core/shell concept (Ruby examples but principles apply universally)