You are viewing a preview of this lesson. Sign in to start learning
Back to Functional Programming with F#

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:

  1. Separate concerns: Pure business logic in the core, I/O in the shell
  2. Make decisions, don't execute: Core returns commands, shell executes them
  3. Test without mocks: Pure functions are trivial to test with simple assertions
  4. Push I/O to boundaries: Load all data first, process it purely, execute results
  5. 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

ConceptDescription
Pure CoreContains all business logic using pure functions with no side effects
Imperative ShellThin layer that handles all I/O and executes core's decisions
Side EffectAny operation that interacts with external world (I/O, time, randomness)
CommandData structure representing an action to be executed by the shell
FlowShell โ†’ gather data โ†’ Core โ†’ make decisions โ†’ Shell โ†’ execute
TestingTest core with simple assertions (no mocks), shell with integration tests
Design GoalThick core (most code), thin shell (minimal orchestration)

๐Ÿ“š Further Study