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–11Recap
Steps 12–13The code: before and after
Before
#[derive(Clone)]
struct Invoice { id: String, email: String, amount: u32, due_day: u64 }
trait InvoiceDb {
fn unpaid(&self) -> Vec<Invoice>;
fn add_fee(&mut self, id: &str, fee: u32);
}
trait Mailer { fn send(&mut self, to: &str, text: &str); }
fn charge_overdue(db: &mut dyn InvoiceDb, mailer: &mut dyn Mailer) {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
let today = now.as_secs() / 86_400;
for inv in db.unpaid() {
let days_late = today.saturating_sub(inv.due_day);
if days_late > 30 {
let fee = inv.amount * 5 / 100;
db.add_fee(&inv.id, fee);
mailer.send(&inv.email, &format!("Late fee: {fee} EUR"));
}
}
}
fn main() {
charge_overdue(&mut SqlInvoiceDb, &mut SmtpMailer);
}After
#[derive(Clone)]
struct Invoice { id: String, email: String, amount: u32, due_day: u64 }
struct Charge { id: String, email: String, fee: u32 }
trait InvoiceDb {
fn unpaid(&self) -> Vec<Invoice>;
fn add_fee(&mut self, id: &str, fee: u32);
}
trait Mailer { fn send(&mut self, to: &str, text: &str); }
fn charge_overdue(db: &mut dyn InvoiceDb, mailer: &mut dyn Mailer,
today: u64) {
for c in charges(&db.unpaid(), today) {
db.add_fee(&c.id, c.fee);
mailer.send(&c.email, &format!("Late fee: {} EUR", c.fee));
}
}
fn charges(unpaid: &[Invoice], today: u64) -> Vec<Charge> {
unpaid.iter()
.filter(|inv| is_overdue(inv, today))
.map(|inv| Charge { id: inv.id.clone(), email: inv.email.clone(),
fee: late_fee(inv) })
.collect()
}
fn is_overdue(inv: &Invoice, today: u64) -> bool {
let days_late = today.saturating_sub(inv.due_day);
days_late > 30
}
fn late_fee(inv: &Invoice) -> u32 {
(inv.amount * 5 / 100).min(50)
}
fn main() {
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap();
let today = now.as_secs() / 86_400;
charge_overdue(&mut SqlInvoiceDb, &mut SmtpMailer, today);
}
#[test]
fn check_cap() {
let inv = Invoice { id: "A1".into(), email: "ana@shop.com".into(),
amount: 2000, due_day: 100 };
assert_eq!(charges(&[inv], 131)[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 day, and the database and the mailer sit behind two small traits. Rust's standard library has no calendar date, so a due day is a day number counted from 1970, and today comes from the system clock divided into days. 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, main runs as the nightly job and 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 due day 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. Now main reads the clock and passes the day number in, because main is the edge where the program meets the real world. Every night it still passes the current day, 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 borrows the invoice and takes today. The same invoice and the same day 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 clock 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 main. 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 main did not change, because neither of them knows how a fee is worked out. And the test Finance asked for builds an invoice due on day one hundred and calls charges with a today of day one hundred thirty one, 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 main 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 fixed day numbers 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 system time 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. Rust's own integers already work this way, because min hands back a new number and leaves the amount alone.