Pure Functions & Immutability
The easiest code to trust is code that can’t surprise you: give it the same inputs and it always returns the same answer, and it never changes anything behind your back. That’s a pure function, and it’s the single highest-leverage habit in this whole group. It makes code testable, movable, and safe to change, which is to say it makes simplicity, edge coverage, and locality all easier at once.
You are not writing Haskell, and a program that touches nothing is useless, the whole point is to save to the database and charge the card. The discipline isn’t to avoid effects. It’s to contain them, so most of your code is the easy, pure kind and the messy part is small and cornered.
Pure Functions: no surprises#
What’s expected: Your core logic is written as functions whose output depends only on their inputs, with no hidden reads and no side effects.
A function is pure when two things hold: same input always gives same output, and calling it changes nothing outside itself, no writing to a database, no reading the clock, no mutating a shared variable. The computeTotal from the case study is one: hand it a cart and a discount, it returns a number, every time, touching nothing.
Why this is worth chasing:
- Trivial to test. No database to set up, no mocks, no clock to freeze. Call it with an input, assert the output. Every edge case becomes a one-line test.
- No spooky action. A pure function can’t reach across the codebase and break something far away, because it touches nothing far away. What you see is all it does.
- Safe to move and reuse. With no hidden dependencies, you can call it from anywhere, cache it, run it in parallel, without it behaving differently.
The opposite, a function that reads global state, writes to the DB, and depends on the current time, can only be understood by understanding the whole world around it, and can only be tested by recreating that world.
Functional Core, Imperative Shell#
What’s expected: The decisions live in pure functions; the effects (DB, network, time, randomness) sit in a thin layer at the edge.
You can’t make everything pure, so you organize: push all the thinking into pure functions (the “functional core”), and keep the effects in a thin outer layer (the “imperative shell”) that reads from the world, hands plain data to the core, takes the core’s answer, and writes it back.
function checkout(cartId) {
const cart = db.load(cartId) // read
cart.total = cart.items... // logic + mutation
cart.paidAt = new Date() // clock
db.save(cart) // write
} // untestable without a database + a fixed clock
// shell: does the I/O
const cart = db.load(cartId)
const priced = priceCart(cart, now) // pure core
db.save(priced)
// core: pure, returns a new value
function priceCart(cart, now) { ... } // test with plain data
The payoff: the part that holds the actual rules, priceCart, is pure and exhaustively testable, and the part that touches the database is thin, boring, and rarely changes. Bugs live in logic; this puts the logic where bugs are easiest to find and pin with tests.
Immutability: don’t change what you were handed#
What’s expected: You return new values instead of mutating inputs or shared state, so a caller’s data never changes under them.
Mutating an object you were given is spooky action at a distance: the caller passed you their cart to read, and you quietly changed it, so now their copy is different and they have no idea why. Bugs like that are miserable to find because the damage happens far from where it shows up. The fix is to treat inputs as read-only and return a new value instead of editing the old one.
Bad: cart.items.push(item) — the caller’s cart just changed.
Good: return { ...cart, items: [...cart.items, item] } — a new cart; the original is untouched.
This is why our domain value objects are immutable, built once, readonly, never mutated in place. A Money or a RequestStatus that can’t be changed after construction can’t be corrupted by some distant caller; if you want a different value, you make a new one. Immutable data has one more quiet benefit: it’s easy to reason about because it doesn’t move. The value you logged is still the value you have.
Isolate the Unpredictable#
What’s expected: Time, randomness, and I/O are pushed to the edge or passed in, so the core stays deterministic.
The enemies of a pure, testable core are the things that give different answers each run: Date.now(), random ids, the network, the database. Don’t sprinkle them through your logic, where they make it impossible to test the same way twice. Pass them in or push them out. Instead of computeTotal calling Date.now() itself, hand it the time. Now a test can pass a fixed time and get a fixed answer.
This is exactly why our database layer is dumb and the domain controls its own ids and timestamps: values come from the domain, deterministically, not from a defaultNow() hidden in the schema. Same principle, keep the unpredictable at the edge so the core stays honest.
Not Dogma, Leverage#
This isn’t purity for its own sake. A program with no effects does nothing. The point is proportion: make the core, where the rules and the bugs live, pure and immutable and trivially testable, and keep the effects thin and at the boundary. Do that and most of your code becomes the kind you can trust at a glance, change without fear, and test in one line. That’s the whole reason to bother.
Next: Think in Events