Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 46

Crafting Pure Functions for Robust Applications

Recommended resources

Introduction

In our last lesson, we explored the nuances of map, filter, and lambda, establishing a clear preference for comprehensions and generator expressions as the more Pythonic tools for data transformation. We saw how pure, side-effect-free operations are at the heart of functional patterns.

This lesson is the capstone of our module on function design. We will synthesize everything we've learned into a powerful architectural principle: designing small, testable, pure functions and pushing side-effects to the application's edges. This pattern, often called "Functional Core, Imperative Shell," is one of the most significant shifts you can make to move from writing code that simply works to writing code that is robust, maintainable, and professional.

By the end of this lesson, you will be able to refactor complex, tangled functions into a clean architecture that separates core logic from real-world interactions, making your code dramatically easier to test and reason about.


1. Pure Functions vs. Side Effects

First, let's establish our key definitions.

A pure function has two strict properties:

  1. Deterministic: Given the same inputs, it always returns the same output.
  2. No Side Effects: Its execution does not change any observable state outside its own scope. It doesn't read from a file, write to a database, call an API, print to the console, or modify its input arguments.

A side effect is any interaction a function has with the outside world beyond returning a value. Common examples include:

  • I/O operations (reading/writing files, print(), input())
  • Network requests
  • Modifying a global variable or a non-local state
  • Generating random numbers
  • Getting the current time or date
  • Mutating the original caller's data (e.g., modifying a list passed as an argument)

While side effects are necessary for any useful program to interact with the world, mixing them with your core logic creates code that is unpredictable and difficult to test.

The following video explains this distinction clearly, emphasizing that even a simple print() statement is a side effect that should be handled with care.

7 Functional Programming Techniques EVERY Developer Should Know

Watch this segment from ArjanCodes' video "7 Functional Programming Techniques EVERY Developer Should Know". It provides a concise definition of pure functions and explains why they are so valuable for testing and debugging.

Watch from 08:38 to 11:23. Pay close attention to the definition of a pure function and the examples of side effects, including the discussion of why print() qualifies. The key idea is to group these side effects and move them to the edges of your application.


2. The "Functional Core, Imperative Shell" Pattern

The solution to managing side effects is not to eliminate them, but to organize them. The "Functional Core, Imperative Shell" pattern is a design that isolates pure logic from impure interactions.

The Functional Core, Imperative Shell Pattern
This diagram illustrates the core concept: a protected, testable 'Functional Core' containing pure domain logic, surrounded by an 'Imperative Shell' that handles all messy, real-world side effects.

Let's break it down:

  • The Functional Core: This is the center of your application. It's composed entirely of pure functions. It receives data, performs calculations and transformations, and returns data. It knows nothing about where the data came from (a file, an API, user input) or where it's going. This core is easy to test because it's deterministic.
  • The Imperative Shell: This is the outer layer of your application. It is the only part of your code that is allowed to have side effects. Its job is to:
    1. Acquire data from the outside world (e.g., read a file, call an API).
    2. Pass that data to the functional core for processing.
    3. Take the results from the core and deliver them to the outside world (e.g., write to a file, print to the console).

The following article provides excellent visuals and a clear explanation of this architectural goal.

Dealing with side effects

This article, "Dealing with side effects" by Oleg Sinavski, provides a brilliant visual breakdown of refactoring from a 'dirty' application to one with a clean 'Functional core, imperative shell'.

Read the first two main sections, "Minimize side effects" and "What are we striving for?". The diagrams contrasting a 'Dirty application' with a 'Clean application' are especially insightful.


3. A Concrete Example: From "Bad" to "Good"

Let's see this pattern in action. You're familiar with writing code in a more imperative, Java-like style where a single method might handle input, logic, and output. This often leads to large, untestable functions.

The following video demonstrates a perfect "bad vs. good" refactoring of a calculator function. The "bad" version is a tangled mess of logic and side effects (file writing). The "good" version separates these concerns, resulting in a testable, maintainable design.

How to Write Production Python Code

In this segment from "How to Write Production Python Code," Tim from Tech With Tim refactors a 'bad calculator' into a set of small, testable components, perfectly illustrating the separation of concerns.

Watch from 22:53 to 26:09. Notice how the initial calculate function is responsible for parsing, calculating, and file I/O, making it nearly impossible to test reliably. The refactored version breaks this down into pure, single-responsibility functions, with a separate component for handling the history (the side effect).

After watching the video, look at this image. It shows the code structure for a clean application. You should now be able to connect the refactored calculator from the video to this architectural blueprint. The main function is the Imperative Shell, and all the f and g functions form the Functional Core.

Separating Pure Functions from Side Effects in Python
This code snippet visually represents the 'Functional Core, Imperative Shell' pattern. The `main` function (the shell) handles all side effects (red arrows), while the core logic is contained in a series of pure, testable functions (green arrows) that just process data.

4. The Refactoring Playbook

How do you transform a "dirty" function into a clean architecture? The process involves inverting the flow of control, a concept often called "hoisting I/O".

The Playbook:

  1. Identify the Side Effects: Find every line in your function that interacts with the outside world (open(), requests.get(), print(), datetime.now(), etc.).
  2. Hoist Inputs: For any side effect that gets data, refactor your function to receive that data as an argument.
    • Bad: def process_report(): now = datetime.now() ...
    • Good: def process_report(now): ...
  3. Defer Outputs: For any side effect that sends data, refactor your function to return the data. The caller becomes responsible for the output.
    • Bad: def format_user(user): ...; print(f"User: {user.name}")
    • Good: def format_user(user): ...; return f"User: {user.name}"

The "Dealing with side effects" article provides a fantastic, concrete example of this. It shows how passing configuration data in memory ("short-circuiting") is vastly superior to writing and reading it from the filesystem.

Dealing with side effects

Let's return to Oleg Sinavski's article. These sections provide a step-by-step guide to refactoring, complete with code examples.

Read the sections "Simple refactoring flow" and "Short-circuit side effects". The image augmentation example shows how to turn side-arguments into parameters and side-results into return values. The config file example is a masterclass in removing unnecessary I/O by passing data directly.

This simple inversion—passing data in, returning data out—is the key to decoupling your logic from the world.


5. It's Not Just About I/O: Decoupling Your Core

This architectural pattern provides benefits even in code with no I/O. A deep call stack of pure functions still creates tight coupling, making components harder to test and reuse independently.

The goal is to create a flat "pipeline" of transformations in your imperative shell, rather than a deep, nested hierarchy of function calls.

Turn that code upside down :)

This final article, "Turn that code upside down :)" by Andrew T. Biehl, takes the concept to its logical conclusion. It argues that even pure functions should be decoupled from each other at the core.

This is a more advanced read, but the insights are invaluable. Read "Turn that I/O upside down" for another excellent example of hoisting I/O. Read "Functional Core, Imperative Shell" to see the formal name for this pattern. The key section is "Subroutine considered harmful?". It demonstrates how a function like create_msg that calls is_special internally is tightly coupled. Refactoring it to create_msg(date, is_special_result) makes it more testable and reusable. This is a subtle but profound point about writing truly modular code.

By parameterizing dependencies instead of hardcoding them as subroutine calls, you make each function a truly independent and testable unit.


Exercise: Refactor a "Dirty" Function

You have a function that gets a random programming joke from an API, processes it, and saves it to a file. It's a classic example of tangled logic and side effects.

Your Task: Refactor the save_random_joke function into a clean "Functional Core, Imperative Shell" architecture.

The "Dirty" Code:

import requests
import textwrap




# --- Dirty Version ---
def save_random_joke():
    """
    Fetches a random joke, checks if it's a single-line joke,
    wraps it to 50 characters, and saves it to a file.
    """
    try:



        # Side effect: Network I/O
        response = requests.get(
            "https://official-joke-api.appspot.com/jokes/programming/random"
        )
        response.raise_for_status()
        joke_data = response.json()[0]




        # Core logic mixed with side effects
        if joke_data["type"] == "general":
            full_joke = f"{joke_data['setup']} - {joke_data['punchline']}"
            



            # More core logic
            wrapped_joke = textwrap.fill(full_joke, width=50)




            # Side effect: File I/O
            with open("joke.txt", "w") as f:
                f.write(wrapped_joke)
            



            # Side effect: Console I/O
            print("Joke saved successfully!")
        else:
            print("Joke was not a single-line joke, not saved.")

    except requests.exceptions.RequestException as e:



        # Side effect: Console I/O
        print(f"Error fetching joke: {e}")




# Run the dirty function
# save_random_joke()
Solution

Here is the refactored code. Notice the clear separation of responsibilities.

import requests
import textwrap
from typing import Dict, Optional




# --- Functional Core ---

def format_joke(joke_data: Dict) -> Optional[str]:
    """
    Pure function: Takes joke data and returns a formatted string
    if it's a 'general' type joke, otherwise None.
    """
    if joke_data.get("type") == "general":
        return f"{joke_data.get('setup', '')} - {joke_data.get('punchline', '')}"
    return None

def wrap_text(text: str, width: int) -> str:
    """
    Pure function: Takes text and a width and returns wrapped text.
    """
    return textwrap.fill(text, width=width)




# --- Imperative Shell ---

def main():
    """
    The shell that orchestrates the workflow and handles all side effects.
    """



    # Side effect: Network I/O
    try:
        response = requests.get(
            "https://official-joke-api.appspot.com/jokes/programming/random"
        )
        response.raise_for_status()
        joke_data = response.json()[0]
    except requests.exceptions.RequestException as e:



        # Side effect: Console I/O
        print(f"Error: Could not fetch joke from API. {e}")
        return # Early exit




    # Call the functional core
    formatted_joke = format_joke(joke_data)

    if formatted_joke:



        # Call the functional core again
        final_joke_text = wrap_text(formatted_joke, width=50)




        # Side effect: File I/O
        try:
            with open("joke_clean.txt", "w") as f:
                f.write(final_joke_text)



            # Side effect: Console I/O
            print("Joke saved successfully to joke_clean.txt!")
        except IOError as e:



            # Side effect: Console I/O
            print(f"Error: Could not write to file. {e}")
    else:



        # Side effect: Console I/O
        print("Joke was not a single-line joke, not saved.")


if __name__ == "__main__":
    main()

Testing the Core:

Now, testing our core logic is trivial. We don't need a network connection or a real filesystem.

def test_format_joke():
    general_joke = {"type": "general", "setup": "s", "punchline": "p"}
    assert format_joke(general_joke) == "s - p"
    
    two_part_joke = {"type": "programming", "setup": "s", "punchline": "p"}
    assert format_joke(two_part_joke) is None

def test_wrap_text():
    long_text = "a" * 100
    wrapped = wrap_text(long_text, width=50)
    assert len(wrapped.splitlines()) == 2
    assert wrapped.splitlines()[0] == "a" * 50

print("Tests passed!")
test_format_joke()
test_wrap_text()

Conclusion

You have now learned a fundamental principle of robust software design that directly helps you stop writing Java-style code (where methods often mutate object state) and start writing clean, composable, Pythonic functions.

Key Takeaways:

  • Pure Functions are Testable: Their deterministic, isolated nature makes them trivial to unit test.
  • Side Effects are Inevitable but Manageable: Don't mix them with logic. Isolate them.
  • Embrace "Functional Core, Imperative Shell": This architecture separates the "what" (pure logic) from the "how" (impure interactions), leading to code that is vastly easier to maintain, test, and reason about.
  • The Refactoring Playbook is Simple: Hoist inputs into arguments and defer outputs to return values.

In the next module, we will shift our focus from the design of functions to their internal implementation. Having learned how to structure our application with clean functions, we will now learn how to structure the code inside those functions, starting with techniques like guard clauses and early returns to eliminate nested if statements and create flat, readable control flow.