Pure Functions — Practice Tasks¶
Twelve hands-on exercises that turn impure, hard-to-test code into pure cores wrapped by thin effectful shells. Every task gives you a scenario, real impure code (Go / Java / Python — rotated), a precise instruction, and a collapsible full solution with the reasoning behind each move. Work top to bottom: difficulty climbs from "inject one dependency" to "carve a pure decision engine out of an I/O handler and prove it with property tests."
Table of Contents¶
- Inject a Clock to Make a Time-Dependent Function Pure (Go)
- Inject a Seeded RNG (Python)
- Remove a Hidden Log Side Effect (Java)
- Stop Reading a Mutable Global (Go)
- Stop Mutating an Argument (Python)
- Pure Core + Thin Effectful Shell (Go)
- Make a Function Safely Memoizable (Java)
- Extract Pure Decision Logic from an I/O Handler (Python)
- Remove a Hidden Metrics/Counter Side Effect (Go)
- Property-Based Test Enabled by Purity (Python)
- Purify a Tangled Pricing Engine (Java)
- Purity Audit + Refactor Plan (Go — open-ended)
How to Use¶
- Read the impure code first and predict the failure. Before opening the solution, name what makes the function impure: a hidden read (clock, global, RNG), a hidden write (log, metric, mutated argument), or both. The smell is the lesson.
- Try it yourself. Write your version, then diff it against the solution. The structural shape matters more than identical names.
- Watch the shell shrink. Across the medium and hard tasks, the recurring move is the same: push effects (I/O, time, randomness, logging) to the edges and leave a deterministic core that takes inputs and returns outputs. The pure core is where the logic lives; the shell is where the world touches it.
- Verify with a test. Each solution is testable with no mocks beyond a fake clock or fixed seed. If your refactor still needs a mocking framework to test the logic, the core is not pure yet.
The mental model for the whole set:
Task 1 — Inject a Clock (Go) — Easy¶
Scenario. A subscription check calls time.Now() inside the function. Every test that exercises an expiry boundary has to either sleep, freeze the system clock, or accept that it passes today and fails next year.
Impure code:
package billing
import "time"
type Subscription struct {
ExpiresAt time.Time
}
// IsExpired reads the wall clock — impure and untestable at the boundary.
func IsExpired(s Subscription) bool {
return time.Now().After(s.ExpiresAt)
}
Instruction. Make IsExpired pure by injecting the "current time" as data. Then show a test that pins the boundary without sleeping. Keep a thin convenience wrapper for production callers.
Solution
package billing
import "time"
type Subscription struct {
ExpiresAt time.Time
}
// IsExpiredAt is PURE: same (sub, now) always yields the same bool.
func IsExpiredAt(s Subscription, now time.Time) bool {
return now.After(s.ExpiresAt)
}
// IsExpired is the thin shell: it reads the clock once, then delegates.
// The clock read lives here, at the edge — not inside the logic.
func IsExpired(s Subscription) bool {
return IsExpiredAt(s, time.Now())
}
func TestIsExpiredAt(t *testing.T) {
exp := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
sub := Subscription{ExpiresAt: exp}
if IsExpiredAt(sub, exp.Add(-time.Second)) {
t.Error("one second before expiry must not be expired")
}
if !IsExpiredAt(sub, exp.Add(time.Second)) {
t.Error("one second after expiry must be expired")
}
// The exact boundary: After is strict, so now == ExpiresAt is NOT expired.
if IsExpiredAt(sub, exp) {
t.Error("exactly at expiry must not be expired (After is exclusive)")
}
}
Task 2 — Inject a Seeded RNG (Python) — Easy¶
Scenario. A token generator calls the module-level random functions. Two test runs produce two different tokens, so you can't assert on the output, and a flaky "collision" test occasionally fails for real.
Impure code:
import random
import string
def generate_token(length: int = 8) -> str:
# Reaches into global RNG state — non-deterministic, untestable output.
return "".join(random.choice(string.ascii_uppercase) for _ in range(length))
Instruction. Make the function pure with respect to its randomness by injecting a random.Random instance (the source of entropy becomes an argument). Show a deterministic test using a fixed seed.
Solution
import random
import string
def generate_token(rng: random.Random, length: int = 8) -> str:
"""Pure relative to `rng`: a given rng-state + length yields a fixed token.
The function no longer *owns* entropy; the caller supplies it. This is the
'dependency rejection' move applied to randomness.
"""
alphabet = string.ascii_uppercase
return "".join(rng.choice(alphabet) for _ in range(length))
# Thin shell for production: seed from the OS once, then delegate.
def generate_token_secure(length: int = 8) -> str:
return generate_token(random.Random(), length)
def test_generate_token_is_deterministic_under_seed():
rng = random.Random(42)
assert generate_token(rng, 6) == generate_token(random.Random(42), 6)
def test_generate_token_length_and_alphabet():
token = generate_token(random.Random(1), 8)
assert len(token) == 8
assert all(c in string.ascii_uppercase for c in token)
Task 3 — Remove a Hidden Log Side Effect (Java) — Easy¶
Scenario. A discount calculator writes to a logger every time it runs. Code review flags it: the method is named like a calculation but secretly performs I/O, so calling it in a tight loop floods the logs, and a unit test for the math has to configure a logging backend.
Impure code:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
class DiscountCalculator {
private static final Logger log = LoggerFactory.getLogger(DiscountCalculator.class);
public double discountFor(double subtotal, boolean isMember) {
double rate = isMember ? 0.10 : 0.0;
double discount = subtotal * rate;
log.info("Computed discount {} for subtotal {} (member={})", discount, subtotal, isMember);
return discount;
}
}
Instruction. Make discountFor pure by removing the logging side effect. Move the log to the calling shell so observability is preserved but the math stays a pure function.
Solution
// Pure core: no logger field, no I/O, just arithmetic.
class DiscountCalculator {
public double discountFor(double subtotal, boolean isMember) {
double rate = isMember ? 0.10 : 0.0;
return subtotal * rate;
}
}
// Effectful shell: it owns the logger and logs around the pure call.
class CheckoutService {
private static final Logger log = LoggerFactory.getLogger(CheckoutService.class);
private final DiscountCalculator calc = new DiscountCalculator();
public double applyDiscount(double subtotal, boolean isMember) {
double discount = calc.discountFor(subtotal, isMember); // pure
log.info("Computed discount {} for subtotal {} (member={})", discount, subtotal, isMember);
return discount;
}
}
Task 4 — Stop Reading a Mutable Global (Go) — Easy¶
Scenario. A tax function reads a package-level TaxRate variable that some config-loading code mutates at startup. Tests pass or fail depending on import order and on whether a previous test changed the global.
Impure code:
package tax
// Mutated at startup by config loading; read by the function below.
var TaxRate = 0.07
// Total is impure: its result depends on hidden global state.
func Total(subtotal float64) float64 {
return subtotal + subtotal*TaxRate
}
Instruction. Make Total pure by passing the rate in as a parameter instead of reading the global. Keep the global only as a default the shell supplies, if you want backward compatibility.
Solution
package tax
// Pure: result is fully determined by its arguments.
func Total(subtotal, rate float64) float64 {
return subtotal + subtotal*rate
}
Task 5 — Stop Mutating an Argument (Python) — Medium¶
Scenario. A function "normalizes" a list of line items. It claims to return the normalized list, but it also mutates the caller's list in place. A caller that keeps a reference to the original is surprised when its data changes underneath it, and a "before/after" test can't compare because before got clobbered.
Impure code:
def normalize_items(items: list[dict]) -> list[dict]:
for item in items: # mutates each dict the caller still holds
item["name"] = item["name"].strip().title()
item["qty"] = max(0, item["qty"])
items.sort(key=lambda i: i["name"]) # mutates the caller's list order too
return items
Instruction. Make normalize_items pure: it must not touch the input. Return a brand-new normalized list, leaving the argument exactly as it was passed.
Solution
def normalize_items(items: list[dict]) -> list[dict]:
"""Pure: builds and returns new data; the input is never mutated."""
normalized = [
{
**item,
"name": item["name"].strip().title(),
"qty": max(0, item["qty"]),
}
for item in items
]
# sorted() returns a NEW list; .sort() would mutate in place.
return sorted(normalized, key=lambda i: i["name"])
def test_normalize_does_not_mutate_input():
original = [{"name": " banana ", "qty": -3}, {"name": "apple", "qty": 2}]
snapshot = [dict(i) for i in original] # deep-ish copy for comparison
result = normalize_items(original)
assert original == snapshot # input unchanged
assert result == [{"name": "Apple", "qty": 2},
{"name": "Banana", "qty": 0}] # new, normalized, sorted
assert result is not original # different object
Task 6 — Pure Core + Thin Effectful Shell (Go) — Medium¶
Scenario. A single function reads a file, parses CSV rows into orders, filters out the cancelled ones, sums the totals, and prints the result. It mixes file I/O, parsing, business logic, and output. You can't test the "sum non-cancelled orders" rule without a real file on disk.
Impure code:
package report
import (
"encoding/csv"
"fmt"
"os"
"strconv"
)
func PrintActiveRevenue(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
rows, err := csv.NewReader(f).ReadAll()
if err != nil {
return err
}
var total float64
for _, row := range rows {
status := row[2]
if status == "CANCELLED" {
continue
}
amt, _ := strconv.ParseFloat(row[1], 64)
total += amt
}
fmt.Printf("Active revenue: %.2f\n", total)
return nil
}
Instruction. Split this into a pure core that takes already-parsed orders and returns the revenue, and a thin shell that does file I/O and printing. Test the core with no files and no stdout capture.
Solution
package report
import (
"encoding/csv"
"fmt"
"os"
"strconv"
)
type Order struct {
ID string
Amount float64
Status string
}
// PURE CORE: orders in, number out. No I/O, no clock, no globals.
func ActiveRevenue(orders []Order) float64 {
var total float64
for _, o := range orders {
if o.Status == "CANCELLED" {
continue
}
total += o.Amount
}
return total
}
// SHELL: all effects live here — file read, parse, print.
func PrintActiveRevenue(path string) error {
orders, err := loadOrders(path)
if err != nil {
return err
}
fmt.Printf("Active revenue: %.2f\n", ActiveRevenue(orders))
return nil
}
func loadOrders(path string) ([]Order, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
rows, err := csv.NewReader(f).ReadAll()
if err != nil {
return nil, err
}
orders := make([]Order, 0, len(rows))
for _, row := range rows {
amt, err := strconv.ParseFloat(row[1], 64)
if err != nil {
return nil, fmt.Errorf("bad amount %q: %w", row[1], err)
}
orders = append(orders, Order{ID: row[0], Amount: amt, Status: row[2]})
}
return orders, nil
}
func TestActiveRevenue(t *testing.T) {
orders := []Order{
{"1", 100, "PAID"},
{"2", 50, "CANCELLED"},
{"3", 25, "PENDING"},
}
if got := ActiveRevenue(orders); got != 125 {
t.Errorf("ActiveRevenue = %v, want 125", got)
}
if got := ActiveRevenue(nil); got != 0 {
t.Errorf("empty = %v, want 0", got)
}
}
Task 7 — Make a Function Safely Memoizable (Java) — Medium¶
Scenario. Someone wrapped an "expensive" shipping-cost function in a cache to speed it up. The cache returns stale values: the function secretly reads the current date to apply a weekend surcharge, so a value cached on Friday is wrong on Saturday. Memoization is only correct on pure functions, and this one isn't.
Impure code:
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.Map;
class ShippingCalculator {
private final Map<Double, Double> cache = new HashMap<>();
// BUG: cached by weight, but the result also depends on today's day-of-week.
public double cost(double weightKg) {
return cache.computeIfAbsent(weightKg, this::computeCost);
}
private double computeCost(double weightKg) {
double base = weightKg * 2.5;
if (LocalDate.now().getDayOfWeek() == DayOfWeek.SATURDAY
|| LocalDate.now().getDayOfWeek() == DayOfWeek.SUNDAY) {
base *= 1.5; // weekend surcharge — a hidden time-dependent input!
}
return base;
}
}
Instruction. Make the cost function pure so it becomes legitimately memoizable. The hidden time input must become an explicit parameter, and the cache key must include every input the result depends on.
Solution
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
class ShippingCalculator {
// PURE: cost is a function of (weight, day). Same inputs -> same output, forever.
public double cost(double weightKg, DayOfWeek day) {
double base = weightKg * 2.5;
boolean weekend = day == DayOfWeek.SATURDAY || day == DayOfWeek.SUNDAY;
return weekend ? base * 1.5 : base;
}
}
// Memoization is now CORRECT because the key contains all inputs.
class MemoizedShipping {
private final ShippingCalculator calc = new ShippingCalculator();
private final Map<Key, Double> cache = new ConcurrentHashMap<>();
private record Key(double weightKg, DayOfWeek day) {}
public double cost(double weightKg, DayOfWeek day) {
return cache.computeIfAbsent(new Key(weightKg, day),
k -> calc.cost(k.weightKg(), k.day()));
}
}
// SHELL: reads the clock once, then calls the pure (and now cacheable) function.
class ShippingShell {
private final MemoizedShipping shipping = new MemoizedShipping();
public double costToday(double weightKg) {
return shipping.cost(weightKg, LocalDate.now().getDayOfWeek());
}
}
@Test
void weekendSurchargeApplies() {
var calc = new ShippingCalculator();
assertEquals(25.0, calc.cost(10, DayOfWeek.MONDAY), 1e-9);
assertEquals(37.5, calc.cost(10, DayOfWeek.SATURDAY), 1e-9);
}
@Test
void cacheKeyDistinguishesDays() {
var memo = new MemoizedShipping();
assertEquals(25.0, memo.cost(10, DayOfWeek.MONDAY), 1e-9);
assertEquals(37.5, memo.cost(10, DayOfWeek.SATURDAY), 1e-9); // not the stale Monday value
}
Task 8 — Extract Pure Decision Logic from an I/O Handler (Python) — Medium¶
Scenario. An HTTP handler decides whether to grant a user access to a resource. The authorization rules — role checks, ownership, account status — are tangled with request parsing, database lookups, logging, and response building. The rules are the part most likely to have bugs, yet they're the hardest to test because every test needs a fake request and a fake database.
Impure code:
def handle_access(request, db, logger):
user_id = request.headers.get("X-User-Id")
if user_id is None:
return Response(401, "missing user id")
user = db.get_user(user_id)
doc = db.get_document(request.path_params["doc_id"])
if user is None or doc is None:
logger.warning("access miss: user=%s", user_id)
return Response(404, "not found")
# --- the actual authorization rules, buried in I/O ---
if user["status"] != "active":
return Response(403, "account inactive")
if doc["owner_id"] == user_id:
granted = True
elif user["role"] == "admin":
granted = True
elif doc["visibility"] == "public":
granted = True
else:
granted = False
if not granted:
logger.info("denied user=%s doc=%s", user_id, doc["id"])
return Response(403, "forbidden")
db.record_access(user_id, doc["id"])
return Response(200, doc["body"])
Instruction. Extract the authorization rules into a pure function decide_access(user, doc) -> Decision that takes plain data and returns a decision value (not a Response, not a log call). Leave the handler as a thin shell that fetches data, calls the pure decision, and performs effects based on it.
Solution
from dataclasses import dataclass
from enum import Enum, auto
class Access(Enum):
GRANTED = auto()
INACTIVE = auto()
FORBIDDEN = auto()
@dataclass(frozen=True)
class Decision:
access: Access
reason: str # for the shell to log/return; the core just states the verdict
# PURE CORE: (user, doc) -> Decision. No request, no db, no logger, no Response.
def decide_access(user: dict, doc: dict) -> Decision:
if user["status"] != "active":
return Decision(Access.INACTIVE, "account inactive")
if doc["owner_id"] == user["id"]:
return Decision(Access.GRANTED, "owner")
if user["role"] == "admin":
return Decision(Access.GRANTED, "admin")
if doc["visibility"] == "public":
return Decision(Access.GRANTED, "public document")
return Decision(Access.FORBIDDEN, "no matching grant")
# SHELL: parsing, lookups, logging, audit write, response building.
def handle_access(request, db, logger):
user_id = request.headers.get("X-User-Id")
if user_id is None:
return Response(401, "missing user id")
user = db.get_user(user_id)
doc = db.get_document(request.path_params["doc_id"])
if user is None or doc is None:
logger.warning("access miss: user=%s", user_id)
return Response(404, "not found")
decision = decide_access(user, doc) # the one pure call
if decision.access is Access.GRANTED:
db.record_access(user["id"], doc["id"])
return Response(200, doc["body"])
if decision.access is Access.INACTIVE:
return Response(403, decision.reason)
logger.info("denied user=%s doc=%s reason=%s", user_id, doc["id"], decision.reason)
return Response(403, decision.reason)
def test_decide_access_rules():
active = {"id": "u1", "status": "active", "role": "user"}
owned = {"id": "d1", "owner_id": "u1", "visibility": "private"}
assert decide_access(active, owned).access is Access.GRANTED
admin = {"id": "u2", "status": "active", "role": "admin"}
private = {"id": "d2", "owner_id": "other", "visibility": "private"}
assert decide_access(admin, private).access is Access.GRANTED
public = {"id": "d3", "owner_id": "other", "visibility": "public"}
assert decide_access(active, public).access is Access.GRANTED
secret = {"id": "d4", "owner_id": "other", "visibility": "private"}
assert decide_access(active, secret).access is Access.FORBIDDEN
inactive = {"id": "u3", "status": "suspended", "role": "user"}
assert decide_access(inactive, owned).access is Access.INACTIVE
Task 9 — Remove a Hidden Metrics/Counter Side Effect (Go) — Medium¶
Scenario. A scoring function increments a Prometheus-style counter every time a request is classified as "high risk." Two problems: the function looks pure but mutates a global metric, and a test that calls it 1,000 times to check the math also pollutes the metrics registry, making metric-based assertions in other tests flaky.
Impure code:
package risk
var highRiskCounter int // package-global, mutated as a side effect
// Score looks pure but secretly bumps a global counter.
func Score(amount float64, country string, newAccount bool) int {
score := 0
if amount > 10000 {
score += 50
}
if country == "XX" {
score += 30
}
if newAccount {
score += 25
}
if score >= 50 {
highRiskCounter++ // hidden write to global state
}
return score
}
Instruction. Make Score pure — it must only compute and return the score. The "high risk" classification can be a pure helper. Move the counter increment into a shell that calls Score, inspects the result, and records the metric.
Solution
package risk
// PURE: score is a function of its three inputs, nothing else.
func Score(amount float64, country string, newAccount bool) int {
score := 0
if amount > 10000 {
score += 50
}
if country == "XX" {
score += 30
}
if newAccount {
score += 25
}
return score
}
// PURE classification helper — still no side effects.
func IsHighRisk(score int) bool {
return score >= 50
}
package risk
// Shell: depends on a metric sink interface, so even the metric is testable.
type Metrics interface {
IncHighRisk()
}
func Evaluate(m Metrics, amount float64, country string, newAccount bool) int {
score := Score(amount, country, newAccount) // pure
if IsHighRisk(score) {
m.IncHighRisk() // the side effect lives here, behind an interface
}
return score
}
func TestScore(t *testing.T) {
if got := Score(20000, "US", false); got != 50 {
t.Errorf("Score = %d, want 50", got)
}
if got := Score(100, "US", false); got != 0 {
t.Errorf("Score = %d, want 0", got)
}
// Calling it a million times changes no global state.
for i := 0; i < 1_000_000; i++ {
_ = Score(100, "US", false)
}
}
type fakeMetrics struct{ highRisk int }
func (f *fakeMetrics) IncHighRisk() { f.highRisk++ }
func TestEvaluateRecordsMetric(t *testing.T) {
m := &fakeMetrics{}
Evaluate(m, 20000, "XX", true) // score 105 -> high risk
if m.highRisk != 1 {
t.Errorf("highRisk = %d, want 1", m.highRisk)
}
}
Task 10 — Property-Based Test Enabled by Purity (Python) — Hard¶
Scenario. You've purified a apply_discount(price_cents, percent) function. Because it's now pure and deterministic, you can test properties that must hold for all inputs, not just a handful of hand-picked examples. The original example-based tests missed a rounding bug at certain percentages.
Impure-ish starting code (already partly cleaned, but with a lurking bug):
def apply_discount(price_cents: int, percent: int) -> int:
# Returns the discounted price in integer cents.
# BUG: float math + int() truncation can produce off-by-one and even negatives.
return int(price_cents - price_cents * (percent / 100))
Instruction. First fix the function so it's pure and correct (integer arithmetic, clamped percent, never negative, never above original). Then write property-based tests (using Hypothesis) that exercise invariants across the whole input space — invariants that are only meaningful because the function is pure.
Solution
def apply_discount(price_cents: int, percent: int) -> int:
"""Pure: integer cents in, integer cents out. No floats, no side effects.
Invariants enforced by construction:
- percent is clamped to [0, 100]
- result is in [0, price_cents]
"""
if price_cents < 0:
raise ValueError("price_cents must be non-negative")
p = max(0, min(100, percent))
# Integer arithmetic: round-half-up without float error.
discount = (price_cents * p + 50) // 100
return price_cents - discount
from hypothesis import given, strategies as st
prices = st.integers(min_value=0, max_value=10_000_000)
percents = st.integers(min_value=-50, max_value=200) # deliberately out of range too
@given(prices, percents)
def test_result_never_exceeds_original(price, percent):
assert apply_discount(price, percent) <= price
@given(prices, percents)
def test_result_never_negative(price, percent):
assert apply_discount(price, percent) >= 0
@given(prices)
def test_zero_percent_is_identity(price):
assert apply_discount(price, 0) == price
@given(prices)
def test_hundred_percent_is_free(price):
assert apply_discount(price, 100) == 0
@given(prices, st.integers(min_value=0, max_value=100), st.integers(min_value=0, max_value=100))
def test_monotonic_in_percent(price, a, b):
# A bigger discount percentage never yields a higher final price.
lo, hi = sorted((a, b))
assert apply_discount(price, hi) <= apply_discount(price, lo)
@given(prices, percents)
def test_referential_transparency(price, percent):
# Calling twice with the same inputs yields the same output — the
# defining property of a pure function, and what makes the cases above
# safe to generalize across the entire input space.
assert apply_discount(price, percent) == apply_discount(price, percent)
Task 11 — Purify a Tangled Pricing Engine (Java) — Hard¶
Scenario. An order-pricing method reads the current time (for happy-hour pricing), reads a mutable global feature-flag, calls a live FX-rate service over the network, logs each step, and mutates the incoming Order object to stamp the computed total. It is impossible to unit test the pricing rules without a network, a clock, a flag registry, and an appender. Everything that can be wrong with purity is wrong here at once.
Impure code:
class PricingEngine {
static boolean HAPPY_HOUR_ENABLED = true; // mutable global flag
double priceOrder(Order order, String targetCurrency) {
double subtotal = 0;
for (LineItem item : order.getItems()) {
subtotal += item.getUnitPriceUsd() * item.getQty();
}
// happy hour: 10% off between 16:00 and 18:00 local time
int hour = LocalTime.now().getHour(); // hidden clock read
if (HAPPY_HOUR_ENABLED && hour >= 16 && hour < 18) { // hidden global read
subtotal *= 0.90;
}
double rate = FxService.liveRate("USD", targetCurrency); // hidden network call
double total = subtotal * rate;
Logger.log("priced order " + order.getId() + " = " + total); // hidden I/O
order.setComputedTotal(total); // mutates the argument
return total;
}
}
Instruction. Refactor into (a) a pure pricing core that takes every input explicitly — items, whether happy hour applies, the FX rate — and returns an immutable result without mutating the order; and (b) a thin shell that gathers the clock reading, flag, and FX rate, calls the core, then logs and persists. List each of the five impurities and where it goes.
Solution
import java.time.LocalTime;
import java.util.List;
// Immutable result — the core returns a value, it does not stamp the order.
record PricedOrder(String orderId, double subtotalUsd, double total, String currency) {}
class PricingEngine {
// PURE CORE: every dependency is an explicit argument.
// (items, happyHour, fxRate, targetCurrency) -> PricedOrder.
PricedOrder price(String orderId, List<LineItem> items,
boolean happyHour, double fxRate, String targetCurrency) {
double subtotal = 0;
for (LineItem item : items) {
subtotal += item.getUnitPriceUsd() * item.getQty();
}
if (happyHour) {
subtotal *= 0.90;
}
double total = subtotal * fxRate;
return new PricedOrder(orderId, subtotal, total, targetCurrency);
}
// PURE helper: the happy-hour rule is itself a deterministic function of time.
static boolean isHappyHour(LocalTime now, boolean flagEnabled) {
int hour = now.getHour();
return flagEnabled && hour >= 16 && hour < 18;
}
}
// SHELL: gathers all effectful inputs, calls the pure core, performs effects.
class PricingService {
private final PricingEngine engine = new PricingEngine();
private final FxService fx;
private final FeatureFlags flags;
private final Clock clock; // java.time.Clock — injectable
private final Logger logger;
private final OrderRepository repo;
PricingService(FxService fx, FeatureFlags flags, Clock clock,
Logger logger, OrderRepository repo) {
this.fx = fx; this.flags = flags; this.clock = clock;
this.logger = logger; this.repo = repo;
}
double priceOrder(Order order, String targetCurrency) {
boolean happyHour = PricingEngine.isHappyHour(
LocalTime.now(clock), flags.isEnabled("HAPPY_HOUR"));
double rate = fx.liveRate("USD", targetCurrency);
PricedOrder result = engine.price( // the one pure call
order.getId(), order.getItems(), happyHour, rate, targetCurrency);
logger.info("priced order {} = {}", result.orderId(), result.total());
repo.saveComputedTotal(order.getId(), result.total()); // persist, don't mutate caller's object
return result.total();
}
}
@Test
void happyHourAppliesTenPercentThenConvertsCurrency() {
var engine = new PricingEngine();
var items = List.of(new LineItem(100.0, 2), new LineItem(50.0, 1)); // 250 USD
// happyHour=true -> 225 USD; fxRate 0.9 -> 202.5 in EUR
var result = engine.price("ord-1", items, true, 0.9, "EUR");
assertEquals(225.0, result.subtotalUsd(), 1e-9);
assertEquals(202.5, result.total(), 1e-9);
assertEquals("EUR", result.currency());
}
@Test
void isHappyHourRespectsTimeAndFlag() {
assertTrue(PricingEngine.isHappyHour(LocalTime.of(16, 30), true));
assertFalse(PricingEngine.isHappyHour(LocalTime.of(15, 59), true));
assertFalse(PricingEngine.isHappyHour(LocalTime.of(16, 30), false)); // flag off
}
Task 12 — Purity Audit + Refactor Plan (Go — open-ended)¶
Scenario. Below is a realistic notification function. Identify every source of impurity, classify each as a hidden input or a hidden output, and write a one-line plan for making it pure-core + thin-shell. Then sketch the resulting signatures.
package notify
import (
"fmt"
"math/rand"
"time"
)
var SentToday int // package global, mutated below
func SendDigest(userID string, unread []Message) error {
// throttle: skip if it's the middle of the night
if time.Now().Hour() < 7 {
return nil
}
// pick a random subject-line variant for A/B testing
variant := rand.Intn(2)
subject := "Your daily digest"
if variant == 1 {
subject = "You have unread messages"
}
body := fmt.Sprintf("Hi %s, you have %d unread messages.", userID, len(unread))
// sort unread in place, newest first
for i := 0; i < len(unread); i++ {
for j := i + 1; j < len(unread); j++ {
if unread[j].CreatedAt.After(unread[i].CreatedAt) {
unread[i], unread[j] = unread[j], unread[i]
}
}
}
if err := smtp.Send(userID, subject, body); err != nil { // network I/O
return err
}
SentToday++ // mutate global counter
fmt.Printf("sent digest to %s\n", userID) // stdout I/O
return nil
}
Solution
**Impurity audit:** | # | Source | Category | Why it's impure | |---|---|---|---| | 1 | `time.Now().Hour()` | hidden input (clock) | Throttle decision depends on wall-clock time not in the signature. | | 2 | `rand.Intn(2)` | hidden input (RNG) | A/B variant depends on global RNG state; output is non-deterministic. | | 3 | in-place sort of `unread` | hidden output (argument mutation) | Reorders the caller's slice via aliasing. | | 4 | `smtp.Send(...)` | hidden output (network I/O) | Sends an email — the whole point, but it's an effect. | | 5 | `SentToday++` | hidden output (global mutation) | Writes shared mutable global state. | | 6 | `fmt.Printf(...)` | hidden output (stdout I/O) | Logging side effect. | **Refactor plan (one line each):** 1. **Clock** → pass `now time.Time` (or a `shouldSend bool`) into the core; the shell reads `time.Now()`. 2. **RNG** → pass the chosen `variant int` (or a `*rand.Rand`) in; the shell draws it once. 3. **Sort** → return a *new* sorted slice from the core via `slices.Clone` + `slices.SortFunc`; never touch the caller's slice. 4. **smtp.Send** → the core returns a `DigestEmail` value (recipient, subject, body); the shell sends it. 5. **Global counter** → drop the global; the shell increments its own counter or a metrics sink after a successful send. 6. **Printf** → the shell logs after sending; the core stays silent. **Resulting signatures:**package notify
import (
"slices"
"time"
)
type DigestEmail struct {
To string
Subject string
Body string
}
// PURE CORE: all inputs explicit, returns a value, mutates nothing.
// Returns (email, ok) — ok=false means "throttled, nothing to send".
func BuildDigest(userID string, unread []Message, hour, variant int) (DigestEmail, bool) {
if hour < 7 {
return DigestEmail{}, false
}
subject := "Your daily digest"
if variant == 1 {
subject = "You have unread messages"
}
sorted := slices.Clone(unread) // copy: never mutate the argument
slices.SortFunc(sorted, func(a, b Message) int {
return b.CreatedAt.Compare(a.CreatedAt) // newest first
})
body := formatBody(userID, len(sorted))
return DigestEmail{To: userID, Subject: subject, Body: body}, true
}
func formatBody(userID string, n int) string {
return fmt.Sprintf("Hi %s, you have %d unread messages.", userID, n)
}
// SHELL: gathers clock + RNG, calls the pure core, performs the effects.
type Sender interface{ Send(to, subject, body string) error }
func SendDigest(s Sender, metrics *Counter, now time.Time,
rng *rand.Rand, userID string, unread []Message) error {
email, ok := BuildDigest(userID, unread, now.Hour(), rng.Intn(2))
if !ok {
return nil // throttled
}
if err := s.Send(email.To, email.Subject, email.Body); err != nil {
return err
}
metrics.Inc()
log.Printf("sent digest to %s", email.To)
return nil
}
Self-Assessment¶
Rate yourself on each skill. You should be able to do all of these without re-reading the solutions.
- Spot a hidden input. Given a function, can you name every clock read, global read, RNG draw, or service call hiding in its body?
- Spot a hidden output. Can you find the logs, metrics, argument mutations, and global writes that don't appear in the return type?
- Turn an input into a parameter. Inject a clock, a seed/RNG, a feature flag, or a fetched value so the function is deterministic.
- Turn an output into a return value. Replace a log/metric/mutation with a value the shell acts on.
- Apply functional core / imperative shell. Split an I/O-heavy function into a pure core plus a thin effectful shell, and articulate which lines went where and why.
- Justify memoization. Explain why a cache is only correct on a pure function, and build a cache key that includes all inputs.
- Write a property test. State an invariant (bounds, monotonicity, identity, round-trip) and test it across the whole input space — and explain why purity is what makes that valid.
- Audit and plan. Walk an unfamiliar function, produce an impurity table (input vs. output), and write the resulting pure-core/shell signatures.
If any box is unchecked, the matching task above (and the in-folder companions below) is where to drill.
Related Topics¶
- junior.md — the beginner's definition of a pure function and why determinism + no side effects matters.
- find-bug.md — buggy snippets where a hidden side effect or mutated argument is the root cause.
- optimize.md — performance work (memoization, batching) that only becomes safe once a function is pure.
- Chapter README — the positive rules for writing pure functions in everyday code.
- Functional Programming — referential transparency, immutability, and the theory behind functional core / imperative shell.
- Refactoring — the mechanical moves (Extract Function, Parameterize, Remove Side Effect) used throughout these solutions.
In this topic