Clean Architecture in Functional Programming: Is It Possible or Not Recommended?

Learning Objectives: By the end of this lesson, you will be able to:

  • Understand the core ideas of Clean Architecture and how they relate to functional programming (FP)
  • Evaluate the compatibility and challenges of applying Clean Architecture in FP
  • Explore practical patterns and examples for functional Clean Architecture

1. Welcome and Introduction (5 minutes)

Icebreaker Activity

Have you ever used a functional programming language (like Haskell, Elixir, F#, or even JavaScript with a functional style)? What differences did you notice in how you structure your code compared to object-oriented programming?

Lesson Overview

  • Duration: 90 minutes
  • Prerequisites: Familiarity with Clean Architecture and basic functional programming concepts
  • Materials Needed: Development environment for a functional language (e.g., Haskell, F#, Elixir, or JavaScript), pen and paper for diagrams

2. Learning Objectives and Session Outline (5 minutes)

What We’ll Cover Today:

  1. Review of Clean Architecture principles
  2. Key differences between OOP and FP
  3. Can Clean Architecture be applied in FP?
  4. Practical patterns and examples
  5. Hands-on exercises
  6. Common pitfalls and recommendations

Success Criteria:

By the end of this session, you should be able to discuss the pros and cons of Clean Architecture in FP and sketch a functional architecture for a simple app.

3. Foundation Knowledge (15-20 minutes)

Core Concepts

Clean Architecture Recap

  • Separation of concerns: entities, use cases, interface adapters, frameworks/drivers
  • Dependency Rule: dependencies point inward
  • Testability, maintainability, and flexibility

Functional Programming (FP) Recap

  • Emphasizes pure functions, immutability, and statelessness
  • Data and behavior are separated (no objects with methods)
  • Side effects are isolated (e.g., via monads, IO types)

Real-World Analogy: OOP is like a company with departments (objects) that own both data and behavior. FP is like a set of procedures (functions) that operate on data, with clear rules about who can change what.

Why This Matters: Clean Architecture was popularized in OOP, but its goals—separation, testability, and independence—are also valued in FP.

Key Terminology

  • Pure Function: A function with no side effects, always returns the same output for the same input
  • Immutability: Data cannot be changed after it is created
  • Effect System: A way to manage side effects in FP (e.g., IO monad)

4. Detailed Explanation and Examples (25-30 minutes)

Can Clean Architecture Be Applied in FP?

Compatibility

  • YES: The core ideas—separation of concerns, dependency inversion, testability—are language-agnostic
  • BUT: The implementation details differ due to FP’s lack of objects and focus on pure functions

How It Looks in FP

  • Entities: Data types (records, structs) and pure functions for business rules
  • Use Cases: Pure functions or modules that orchestrate business logic
  • Interface Adapters: Functions or modules that convert data formats, handle IO, or interact with frameworks
  • Frameworks/Drivers: Modules for HTTP, DB, etc., passed as dependencies

Example: To-Do App in F# (or Pseudocode)

// Entity: Task
 type Task = { Id: string; Description: string; Completed: bool }

// Business rule as a pure function
 let markComplete task = { task with Completed = true }

// Use Case: Add Task
 let addTask repository task =
     repository.save task

// Repository interface as a function signature
 type TaskRepository = { save: Task -> unit; findById: string -> Task option }

// Adapter: In-memory repository
 let inMemoryRepo =
     let store = ref Map.empty
     { save = fun task -> store := (!store).Add(task.Id, task)
       findById = fun id -> (!store).TryFind id }

Explanation:

  • Data and business rules are separated
  • Dependencies (like repositories) are passed as arguments
  • No classes or methods—just functions and data

Practical Applications

  • FP makes it easy to test business logic (pure functions)
  • Dependency injection is done by passing functions/modules
  • Side effects are isolated, making boundaries clear

Common Misconceptions

  1. Misconception: Clean Architecture is only for OOP. Reality: The principles can be applied in FP, but the structure looks different.
  2. Misconception: FP doesn’t need architecture. Reality: Large FP systems benefit from clear boundaries and separation of concerns.

Clean Architecture and Functional Programming: Deeper Dive

How Clean Architecture Maps to FP Concepts

  • Entities: In FP, entities are typically immutable data structures (records, structs, algebraic data types). Business rules are implemented as pure functions that operate on these data structures, rather than as methods on objects.
  • Use Cases: Use cases are orchestrator functions or modules. They compose business rules and coordinate the flow of data, often by chaining or composing pure functions. In larger FP systems, use cases may be grouped in modules or namespaces.
  • Interface Adapters: Adapters are functions or modules that translate between the data formats used by the core and those required by frameworks, databases, or external APIs. They also handle side effects, such as IO, HTTP requests, or database operations, often using effect systems (e.g., IO monad, Task, ZIO, etc.).
  • Frameworks & Drivers: These are the actual implementations of IO, HTTP servers, databases, etc. In FP, these are passed as dependencies (higher-order functions, modules, or records of functions) to the core logic, keeping the core pure and testable.

Dependency Inversion in FP

  • In OOP, dependency inversion is achieved by having core logic depend on interfaces, with concrete implementations injected at runtime.
  • In FP, dependency inversion is achieved by passing functions or modules as arguments. The core logic never imports or references concrete effectful code; it only receives functions that perform the required effects.

Example: Dependency Injection in Haskell (Pseudocode)

-- Entity
data Task = Task { taskId :: String, description :: String, completed :: Bool }

-- Business rule
markComplete :: Task -> Task
markComplete t = t { completed = True }

-- Repository interface as a record of functions
data TaskRepo = TaskRepo { save :: Task -> IO (), findById :: String -> IO (Maybe Task) }

-- Use case as a pure function that returns an effectful action
addTask :: TaskRepo -> Task -> IO ()
addTask repo task = save repo task

Effect Systems and Boundary Management

  • In FP, effect systems (like IO in Haskell, Task in Scala/ZIO, or Elixir’s processes) are used to isolate side effects.
  • The core logic (entities, use cases) remains pure and testable, while adapters and frameworks handle effects.
  • This makes it easy to test the core by passing in mock or in-memory implementations.

Layered Structure in FP Projects

  • Many FP projects use a folder/module structure that mirrors Clean Architecture:
    • Domain or Core: Data types and pure business logic
    • Application or UseCases: Orchestrator functions
    • Adapters or Ports: Functions/modules for IO, DB, HTTP, etc.
    • Infrastructure or Frameworks: Actual effectful implementations

Example: JavaScript (Functional Style)

// Entity: Task (immutable object)
const createTask = (id, description) => ({ id, description, completed: false });
const markComplete = task => ({ ...task, completed: true });

// Repository interface as a record of functions
const makeTaskRepository = (store = new Map()) => ({
  save: task => store.set(task.id, task),
  findById: id => store.get(id)
});

// Use case as a pure function
const addTask = (repo, task) => repo.save(task);

// Adapter: Console presenter
const presentTask = task =>
  console.log(`Task: ${task.id} - ${task.description} [${task.completed ? 'Done' : 'Pending'}]`);

When Clean Architecture in FP is Most Useful

  • Recommended:
    • Large, long-lived, or complex FP projects
    • Projects with multiple external dependencies (DB, APIs, UI)
    • Teams that value testability, maintainability, and clear boundaries
  • Not Always Needed:
    • Small scripts, prototypes, or single-purpose functions
    • Projects where the overhead of layers outweighs the benefits

Challenges and Considerations

  • Boilerplate: FP can require more wiring (passing dependencies explicitly), but this can be mitigated with dependency injection libraries or effect systems.
  • Team Familiarity: Teams new to FP may find the patterns unfamiliar, especially if coming from OOP backgrounds.
  • Tooling: Some FP languages have less mature tooling for dependency injection or effect management compared to OOP frameworks.

Summary Table: OOP vs FP Clean Architecture

Aspect OOP Approach FP Approach
Entities Classes with methods Immutable data + pure functions
Use Cases Service/interactor classes Pure functions/modules
Dependency Injection Interfaces + DI frameworks Passing functions/modules as args
Adapters Classes/objects Functions/modules
Effect Management Mocking, test doubles Effect systems, pure core

5. Live Coding Demonstration (20-25 minutes)

Example 1: Use Case as a Pure Function

// Use case: Complete a task
let completeTask repository id =
    match repository.findById id with
    | Some task ->
        let updated = markComplete task
        repository.save updated
        Some updated
    | None -> None

Example 2: Adapters for Side Effects

// Adapter: Console presenter
let presentTask task =
    printfn "Task: %s - %s [%s]" task.Id task.Description (if task.Completed then "Done" else "Pending")

Example 3: Dependency Injection via Function Arguments

// Main function wires everything together
let main () =
    let repo = inMemoryRepo
    let task = { Id = "1"; Description = "Learn FP"; Completed = false }
    addTask repo task
    let completed = completeTask repo "1"
    match completed with
    | Some t -> presentTask t
    | None -> printfn "Task not found"

Debugging Demonstration

Show a common error:

// Common mistake: Mixing side effects in business logic
let markCompleteAndSave repository task =
    let updated = markComplete task
    repository.save updated // Side effect in business logic (not recommended)
    updated

// Correct: Keep business logic pure, handle effects outside
let markComplete task = { task with Completed = true }

6. Hands-On Practice (30-40 minutes)

Exercise 1: Basic Practice (10 minutes)

Objective: Refactor an impure function to be pure.

Instructions:

  1. Identify a function that mixes business logic and side effects
  2. Separate the pure logic from the effectful code

Starter Code:

let addAndPrintTask repository task =
    repository.save task
    printfn "Added: %s" task.Description

Solution:

let addTask repository task = repository.save task
let printTask task = printfn "Added: %s" task.Description

Exercise 2: Intermediate Challenge (15 minutes)

Objective: Design a functional use case with dependency injection.

Requirements:

  • Define a repository interface as a record of functions
  • Pass the repository to the use case as an argument
  • Ensure all business logic is pure

Hints:

  • Use F#, Haskell, or JavaScript with a functional style

Exercise 3: Creative Application (15 minutes)

Objective: Sketch a Clean Architecture for a functional blog app.

Challenge: Identify entities, use cases, adapters, and frameworks. Draw the data flow and show how dependencies are injected.

Extension Ideas:

  • Use monads or effect systems to manage side effects
  • Try implementing in a language like Haskell or Elixir

7. Common Pitfalls and Troubleshooting (10 minutes)

Frequent Mistakes

  1. Problem: Mixing side effects with business logic Cause: Not separating pure and impure code Solution: Keep business logic pure, handle effects in adapters

  2. Problem: Over-abstracting in small FP projects Cause: Applying all layers rigidly Solution: Use structure as needed, refactor as the project grows

Debugging Tips

  • Use type signatures to enforce purity
  • Write property-based tests for pure functions
  • Isolate and test side-effectful code separately

8. Best Practices and Professional Tips (5 minutes)

Industry Standards

  • Keep business logic pure and testable
  • Pass dependencies as arguments (dependency injection)
  • Use modules or records of functions for interfaces

Next Steps in Your Learning Journey

  • Try Clean Architecture in a functional language
  • Explore effect systems (e.g., IO monad, Task, ZIO)
  • Read about functional design patterns (e.g., Railway Oriented Programming)

9. Summary and Review (5 minutes)

Key Takeaways

  1. Clean Architecture principles can be applied in functional programming
  2. The structure looks different, but the goals are the same
  3. Keep business logic pure and separate from side effects

Quick Review Questions

  1. How do you represent entities and use cases in FP?
  2. Why is it important to separate pure and impure code?
  3. What are the benefits and challenges of Clean Architecture in FP?

For Instructors

Teaching Tips

  • Use both OOP and FP examples for comparison
  • Encourage students to refactor impure code to pure functions
  • Discuss trade-offs and when to use structure

Assessment Ideas

  • Ask students to sketch a functional Clean Architecture for a sample app
  • Review code for separation of pure and impure logic
  • Peer review of functional design patterns

Adaptation Notes

  • For beginners, provide more scaffolding and FP examples
  • For advanced students, challenge them to implement adapters for real-world effects
  • Adjust timing based on class experience and engagement