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:
- Review of Clean Architecture principles
- Key differences between OOP and FP
- Can Clean Architecture be applied in FP?
- Practical patterns and examples
- Hands-on exercises
- 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
- Misconception: Clean Architecture is only for OOP. Reality: The principles can be applied in FP, but the structure looks different.
- 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:
DomainorCore: Data types and pure business logicApplicationorUseCases: Orchestrator functionsAdaptersorPorts: Functions/modules for IO, DB, HTTP, etc.InfrastructureorFrameworks: 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:
- Identify a function that mixes business logic and side effects
- 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
-
Problem: Mixing side effects with business logic Cause: Not separating pure and impure code Solution: Keep business logic pure, handle effects in adapters
-
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
- Clean Architecture principles can be applied in functional programming
- The structure looks different, but the goals are the same
- Keep business logic pure and separate from side effects
Quick Review Questions
- How do you represent entities and use cases in FP?
- Why is it important to separate pure and impure code?
- 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