I'll Be Backend
Principles · Java

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 Java 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

record Invoice(String id, String email, int amount, LocalDate due) {}
interface InvoiceDb {
    List<Invoice> unpaid();
    void addFee(String id, int fee);
}
interface Mailer { void send(String to, String text); }

class LateFees {
    void chargeOverdue(InvoiceDb db, Mailer mailer) {
        LocalDate today = LocalDate.now();
        for (Invoice inv : db.unpaid()) {
            long daysLate = ChronoUnit.DAYS.between(inv.due(), today);
            if (daysLate > 30) {
                int fee = inv.amount() * 5 / 100;
                db.addFee(inv.id(), fee);
                mailer.send(inv.email(), "Late fee: " + fee + " EUR");
            }
        }
    }
}

class NightlyJob {
    public static void main(String[] args) {
        new LateFees().chargeOverdue(new SqlInvoiceDb(), new SmtpMailer());
    }
}

After

record Invoice(String id, String email, int amount, LocalDate due) {}
record Charge(String id, String email, int fee) {}
interface InvoiceDb {
    List<Invoice> unpaid();
    void addFee(String id, int fee);
}
interface Mailer { void send(String to, String text); }

class LateFees {
    void chargeOverdue(InvoiceDb db, Mailer mailer, LocalDate today) {
        for (Charge c : charges(db.unpaid(), today)) {
            db.addFee(c.id(), c.fee());
            mailer.send(c.email(), "Late fee: " + c.fee() + " EUR");
        }
    }

    static List<Charge> charges(List<Invoice> unpaid, LocalDate today) {
        return unpaid.stream()
                .filter(inv -> isOverdue(inv, today))
                .map(inv -> new Charge(inv.id(), inv.email(), lateFee(inv)))
                .toList();
    }

    static boolean isOverdue(Invoice inv, LocalDate today) {
        return ChronoUnit.DAYS.between(inv.due(), today) > 30;
    }

    static int lateFee(Invoice inv) {
        return Math.min(inv.amount() * 5 / 100, 50);
    }
}

class NightlyJob {
    public static void main(String[] args) {
        new LateFees().chargeOverdue(new SqlInvoiceDb(), new SmtpMailer(),
                LocalDate.now());
    }
}

class LateFeesTest {
    static void checkCap() {
        Invoice a1 = new Invoice("A1", "ana@shop.com", 2000,
                LocalDate.of(2026, 1, 1));
        assert LateFees.charges(List.of(a1), LocalDate.of(2026, 2, 1))
                .get(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 interfaces. 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 Math min in 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: isOverdue, the first pure function Extract Function

Now the lateness test moves into its own method, 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: lateFee 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 record carries the result between them. Phase one is charges, which 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 method 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 now 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. Java's own date classes work this way, because now can take a Clock, so the caller picks the time and a test can pin it.