Pure functions: push I/O to the edges
Keep your rules pure, so you can test them without a database.
Watch the narrated video
Every step
Problem
Steps 1–4Refactor
Steps 5–9Payoff
Steps 10–11C++ bonus
Steps 12–13Recap
Steps 14–15The code: before and after
Before
struct Invoice { std::string id, email; int amount; sys_days due; };
struct InvoiceDb {
virtual ~InvoiceDb() = default;
virtual std::vector<Invoice> unpaid() = 0;
virtual void addFee(const std::string& id, int fee) = 0;
};
struct Mailer {
virtual ~Mailer() = default;
virtual void send(const std::string& to, const std::string& text) = 0;
};
struct LateFees {
void chargeOverdue(InvoiceDb& db, Mailer& mailer) {
sys_days today = floor<days>(system_clock::now());
for (const auto& inv : db.unpaid()) {
auto daysLate = (today - inv.due).count();
if (daysLate > 30) {
int fee = inv.amount * 5 / 100;
db.addFee(inv.id, fee);
mailer.send(inv.email, std::format("Late fee: {} EUR", fee));
}
}
}
};
void nightlyJob() {
SqlInvoiceDb db;
SmtpMailer mailer;
LateFees{}.chargeOverdue(db, mailer);
}After
struct Invoice { std::string id, email; int amount; sys_days due; };
struct Charge { std::string id, email; int fee; };
struct InvoiceDb {
virtual ~InvoiceDb() = default;
virtual std::vector<Invoice> unpaid() = 0;
virtual void addFee(const std::string& id, int fee) = 0;
};
struct Mailer {
virtual ~Mailer() = default;
virtual void send(const std::string& to, const std::string& text) = 0;
};
struct LateFees {
void chargeOverdue(InvoiceDb& db, Mailer& mailer, sys_days today) {
for (const auto& c : charges(db.unpaid(), today)) {
db.addFee(c.id, c.fee);
mailer.send(c.email, std::format("Late fee: {} EUR", c.fee));
}
}
static constexpr std::vector<Charge> charges(
const std::vector<Invoice>& unpaid, sys_days today) {
std::vector<Charge> result;
for (const auto& inv : unpaid) {
if (isOverdue(inv, today)) {
result.push_back({inv.id, inv.email, lateFee(inv)});
}
}
return result;
}
static constexpr bool isOverdue(const Invoice& inv, sys_days today) {
return (today - inv.due).count() > 30;
}
static constexpr int lateFee(const Invoice& inv) {
return std::min(inv.amount * 5 / 100, 50);
}
};
void nightlyJob() {
SqlInvoiceDb db;
SmtpMailer mailer;
LateFees{}.chargeOverdue(db, mailer, floor<days>(system_clock::now()));
}
constexpr bool checkCap() {
Invoice a1{"A1", "ana@shop.com", 2000, 2026y / 1 / 1};
return LateFees::charges({a1}, 2026y / 2 / 1)[0].fee == 50;
}
static_assert(checkCap());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 builds the SQL database and the SMTP mailer and hands them to charge overdue.
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 standard 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: isOverdue, 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: 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 struct 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.
C++ bonus
Step 12: A test nothing calls C++ bonus
The test Finance asked for is a plain function, and nothing on screen ever calls check cap. So it proves the cap only when someone wires it into a test runner, and a release build compiles its assert away.
Step 13: The compiler runs the test constexpr + static_assert
C plus plus can run pure code while it compiles, so charges, is overdue and late fee get the constexpr keyword. Check cap becomes constexpr too and returns its answer, and a static assert asks the compiler to work that answer out. Now every build proves the cap, and a change that breaks it never gets past the build. It works because these three are pure, since a constexpr function that read the clock or called the database couldn't be worked out at compile time, and the charge loop keeps its input and output.
Recap
Step 14: 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 15: 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. The standard library's format works this way, because it only builds a string and leaves the writing to whoever calls it.