I'll Be Backend
Principles · Python

Pure functions: push I/O to the edges

Keep your rules pure, so you can test them without a database.

13 steps4 chapters~5 min at 1.25×#pure functions#side effects#functional core#testability
The lesson plays right here. Pause anywhere, step back, or jump to a step below. Watch the Python video on YouTube Code on GitHub

Watch the narrated video

Every step

Problem

Steps 1–4

Refactor

Steps 5–9

Payoff

Steps 10–11

Recap

Steps 12–13

The code: before and after

Before

@dataclass(frozen=True)
class Invoice:
    id: str
    email: str
    amount: int
    due: date


class InvoiceDb(Protocol):
    def unpaid(self) -> list[Invoice]: ...
    def add_fee(self, inv_id: str, fee: int) -> None: ...


class Mailer(Protocol):
    def send(self, to: str, text: str) -> None: ...


def charge_overdue(db: InvoiceDb, mailer: Mailer) -> None:
    today = date.today()
    for inv in db.unpaid():
        days_late = (today - inv.due).days
        if days_late > 30:
            fee = inv.amount * 5 // 100
            db.add_fee(inv.id, fee)
            mailer.send(inv.email, f"Late fee: {fee} EUR")


def nightly_job() -> None:
    charge_overdue(SqlInvoiceDb(), SmtpMailer())

After

@dataclass(frozen=True)
class Invoice:
    id: str
    email: str
    amount: int
    due: date


@dataclass(frozen=True)
class Charge:
    id: str
    email: str
    fee: int


class InvoiceDb(Protocol):
    def unpaid(self) -> list[Invoice]: ...
    def add_fee(self, inv_id: str, fee: int) -> None: ...


class Mailer(Protocol):
    def send(self, to: str, text: str) -> None: ...


def charge_overdue(db: InvoiceDb, mailer: Mailer, today: date) -> None:
    for c in charges(db.unpaid(), today):
        db.add_fee(c.id, c.fee)
        mailer.send(c.email, f"Late fee: {c.fee} EUR")


def charges(unpaid: list[Invoice], today: date) -> list[Charge]:
    return [Charge(inv.id, inv.email, late_fee(inv))
            for inv in unpaid if is_overdue(inv, today)]


def is_overdue(inv: Invoice, today: date) -> bool:
    return (today - inv.due).days > 30


def late_fee(inv: Invoice) -> int:
    return min(inv.amount * 5 // 100, 50)


def nightly_job() -> None:
    charge_overdue(SqlInvoiceDb(), SmtpMailer(), date.today())


def test_cap() -> None:
    a1 = Invoice("A1", "ana@shop.com", 2000, date(2026, 1, 1))
    assert charges([a1], date(2026, 2, 1))[0].fee == 50

Transcript, step by step

Problem

Step 1: A nightly late fee job Before

Each unpaid invoice has an id, a customer email, an amount in whole euros and a due date, and the database and the mailer sit behind two small protocols. Charge overdue loops over the unpaid invoices, and when one is more than thirty days late, it adds a five percent fee and mails the customer. So a two thousand euro invoice gets a one hundred euro fee. Every night, the nightly job calls it with the SQL database and the SMTP mailer.

Step 2: Cap the fee at 50 euros Code smell

Finance caps the fee at fifty euros, and wants a test that proves a two thousand euro invoice, thirty one days late, pays fifty. The cap itself is one call to min inside the loop. The test is where it hurts, because it can only reach that line by running charge overdue against a fake database and a fake mailer.

Step 3: No part of it is pure Code smell

A pure function gives the same answer for the same inputs and changes nothing outside itself, and charge overdue mixes the fee rule with input and output, so no part of it is pure. It reads the clock here, reads the database here, writes the fee here, and sends mail here. So to check one line of fee math, the test builds a fake database and a fake mailer, runs the whole charge loop, and even builds its date from today's clock. The rule it checks is buried under setup, and most teams skip a test like this, so the cap ships with nothing proving it.

Step 4: Back it out, then pull the rule out Plan

Let's back the cap and its test out. We'll refactor first, so the fee rule ends up in pure functions and the input and output move to the edges, and then the cap goes in.

Refactor

Step 5: Pass today in Replace Query with Parameter

The first side effect to go is the clock read, so today becomes a parameter of charge overdue. The nightly job now reads the clock and passes it in, because the job is the edge where the program meets the real world. Every night it still passes today, so the fees come out the same.

Step 6: is_overdue, the first pure function Extract Function

Now the lateness test moves into its own function, is overdue, which takes the invoice and today. Same invoice and same date give the same answer, and it touches nothing outside itself, so is overdue is our first pure function. The loop asks it by name.

Step 7: late_fee holds the math Extract Function

The fee math gets the same treatment, so late fee takes an invoice and returns five percent of its amount. It needs no date and no database, and the loop calls it where the math used to be.

Step 8: Decide first, then do Split Phase

The loop still decides and does in the same place, so we split it into two phases, and a small Charge dataclass carries the result between them. Phase one is charges, a list comprehension that keeps the overdue invoices and turns each one into a Charge with its late fee. It filters on lateness only, so a small invoice with a zero euro fee is still charged, as before. Phase two is charge overdue, which reads the database once, hands the invoices to charges, and loops over the charge list to write each fee and send each mail.

Step 9: Functional core, imperative shell Split Phase

Charges, is overdue and late fee take values and return values, and none of them touches the clock, the database or the mailer. All the input and output now sits in this short loop and in the nightly job. The usual name for this shape is a functional core inside an imperative shell, and in our diagram charges is the core and charge overdue is the shell.

Payoff

Step 10: The cap is one edit Payoff

Now the cap goes in again, and it's one edit inside late fee. The loop in charge overdue and the nightly job did not change, because neither of them knows how a fee is worked out. And the test Finance asked for calls charges with an invoice due on the first of January and a today of the first of February, which is thirty one days late. It expects a fee of fifty, and it passes every day of the year, because nothing in it reads the clock.

Step 11: Why it was easy Payoff

The four side effects are still here, with the clock read in the nightly job here, and the database read, the fee write and the mail send in the shell loop here. The test that needed a fake database, a fake mailer and today's clock now passes two plain dates to charges. This fits here because the fee rule is the part Finance changes and wants tested, while the database and mail calls stay the same from one change to the next. So with the rule pure and the input and output at the edges, the next rule change and its test touch only late fee and charges.

Recap

Step 12: Where we started, where we ended Before / after

On the left, one function reads the clock and the database, writes the fee and sends the mail, with the fee rule stuck in the middle. On the right, is overdue, late fee and charges are pure, so a test hands them values and reads the answer. And charge overdue shrank to a short loop that does the input and output and makes no decisions.

Step 13: When to reach for it Takeaways

Reach for it when a business rule sits inside code that talks to a database, the clock, files or the network, and you want to test the rule on its own. Skip it when the code is only glue, like copying a file and logging that it was copied, because there's no decision in it to pull out. You'll spot it by a call to date today or to the database in the middle of a calculation, or by a test that needs a fake for every collaborator to check one number. Python's own sorted works this way, because it hands back a new list and leaves yours alone, while list sort changes the list in place.