4.1 Pure Functions and Immutable Data: Control Change First
The earlier concurrency models offered three ways to control conflicts: locks over shared memory, actor-style message passing, and transactions such as STM. They share one premise: data changes, so the system must constrain how it changes.
A price function can acquire dependencies on membership, campaign timing, tax rates, remote discounts, and mutable flags. If those inputs stay hidden, passing the same order need not produce the same result. Before blaming the calculator, check what else the function has been reading.
A useful starting point for functional programming is to make data dependencies and state changes visible.
What You Should Be Able to Answer
- The difference between pure functions and "not modifying parameters";
- How referential transparency simplifies testing, caching, and parallel computation;
- How immutable objects and persistent data structures avoid full-object copying;
- Where to place side effects when they cannot be eliminated.
Troubleshooting from Hidden Inputs
final class PricingService {
private BigDecimal campaignRate;
PricingService(BigDecimal rate) { campaignRate = rate; }
BigDecimal finalPrice(Order order) {
var subtotal = order.items().stream()
.map(LineItem::price)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return subtotal.multiply(campaignRate);
}
}finalPrice(order) also reads the mutable field campaignRate. A later rate update can change the result for the same order. Concurrent updates would additionally require a synchronization protocol; making access thread-safe would not remove the hidden dependency.
Move the dependency back into the parameter:
static BigDecimal finalPrice(Order order, PricingPolicy policy) {
var subtotal = order.items().stream()
.map(LineItem::price)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return policy.apply(subtotal);
}This exposes the policy as an input, but the signature alone does not establish purity. The order and policy must represent stable values, and policy.apply must be deterministic and free of externally visible effects. Passing an object that reads a clock or changes internally merely relocates the hidden dependency.
Criteria for Pure Functions
A function is considered pure if it satisfies both of the following conditions:
- It produces the same output for the same input;
- Its evaluation does not alter any observable state outside the function itself.
Therefore, the following operations all constitute side effects or external dependencies:
- Modifying global variables, object fields, or mutable collections passed in;
- Writing to files, databases, networks, or message queues;
- Reading the current time, random numbers, or environment variables;
- Logging messages, or relying on an exceptional exit outside the ordinary result model;
- Depending on the number of function calls or thread scheduling order.
Exception treatment depends on the semantic model. A deterministic exception is not the same as a database write, but it complicates substitution and reordering in ordinary Java control flow. For an expected domain failure, an explicit result often makes reasoning easier.
"Side effects" are not inherently negative. Saving an order requires writing to a database, and sending a notification necessitates accessing external systems. The real issue arises when side effects are scattered throughout the computation, then the caller has no clear understanding of what the code reads, modifies, or might fail to do.
Referential Transparency Enables Reasoning
If an expression can be replaced with its result without changing the program's behavior, it is referentially transparent:
discount(100, VIP) = 80Anywhere in the code, discount(100, VIP) can be substituted with 80. This makes the following optimizations and engineering practices more feasible:
- Independent testing, without needing to set up databases or reset global state;
- Safe caching, because results for old inputs won’t suddenly become invalid;
- Reordering or parallel evaluation of expressions that are independent of one another;
- Local reasoning, where you can understand a function’s behavior simply by examining its parameters and return value.
Yet caching is not automatically faster for pure functions. Caching itself requires storage space, eviction strategies, and concurrency control. It only becomes worthwhile when the input space is well-defined and the function is invoked frequently enough.
Immutability Is Not the Same as final
final List<String> tags = new ArrayList<>();
tags.add("paid"); // Legal: final only prevents reassignmentfinal fixes reference bindings, but does not guarantee that the object being referenced is immutable. True immutability requires:
- The logical state not to change after construction;
- Internal mutable objects not to be exposed;
- Nested fields to satisfy corresponding constraints, or defensive copying at boundaries.
record OrderSnapshot(String id, List<LineItem> items) {
OrderSnapshot {
items = List.copyOf(items);
}
}List.copyOf makes an unmodifiable list with a shallow copy of element references. Each LineItem must also be immutable; otherwise changes to an item remain observable through the snapshot. This boundary also rejects null elements rather than inventing values for them.
Immutable data makes sharing simple: multiple threads can read the same value without coordinating "who is modifying it." Updates produce new values:
record Cart(List<LineItem> items) {
Cart {
items = List.copyOf(items);
}
Cart add(LineItem item) {
var next = new ArrayList<>(items);
next.add(item);
return new Cart(next);
}
}Persistent Data Structures Don't Copy the Entire Tree
In the previous example, each list copy involves duplicating the entire structure, which becomes costly as data size grows. Persistent data structures instead share unchanged internal nodes between versions, copying only the path from root to the modification point:
Old version root ─┬─ A
├─ B
└─ C
New version root' ─┬─ A (shared)
├─ B' (only the modified path is copied)
└─ C (shared)Here, "persistent" means that the old version remains accessible, not that data is written to disk. Clojure's collections use structural sharing and provide performance guarantees for persistent operations. However, the exact complexity depends on the data structure, so it's incorrect to generalize that all immutable updates are O(log n).
Immutability comes with costs: short-lived objects, cache locality, serialized size, and construction overhead all need to be measured. For localized, exclusive, and performance-critical builds, controlled mutable state may be used during execution, with a final immutable result published afterward.
Functional Core, Imperative Shell
Real-world applications don’t need to pretend I/O is a pure function. A clearer architectural approach is to divide the system into two layers:
Imperative shell: reads database, time, and configuration
↓ assembles explicit input
Functional core: validates, computes, generates decisions
↓ returns a value or a command to execute
Imperative shell: commits transactions, sends messages, logs eventsrecord PricingInput(Order order, PricingPolicy policy, Instant now) {}
record PricingDecision(BigDecimal total, List<String> appliedRules) {}
static PricingDecision decide(PricingInput input) {
return new PricingDecision(
finalPrice(input.order(), input.policy()), List.of("campaign-rate")
);
}
PricingDecision price(String orderId) {
var input = new PricingInput(
repository.load(orderId),
policyRepository.current(),
clock.instant()
);
var decision = decide(input);
auditLog.save(orderId, decision);
return decision;
}This is an application-architecture fragment: imports, domain records, repositories, and the clock are supplied by the enclosing application. The shown core applies one campaign multiplier; now is an explicit input for a future time-dependent rule and is not used by this calculation. Tax, currency, and rounding policies are outside this example. The core can be tested with ordinary values while the shell owns I/O.
Run the Value-Boundary Example
Save this as PricingDemo.java and run java PricingDemo.java with JDK 17 or later:
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
class PricingDemo {
record LineItem(BigDecimal price) {
LineItem { Objects.requireNonNull(price); }
}
record Order(List<LineItem> items) {
Order { items = List.copyOf(items); }
}
record PricingPolicy(BigDecimal rate) {
PricingPolicy { Objects.requireNonNull(rate); }
BigDecimal apply(BigDecimal subtotal) { return subtotal.multiply(rate); }
}
static BigDecimal finalPrice(Order order, PricingPolicy policy) {
return policy.apply(order.items().stream().map(LineItem::price)
.reduce(BigDecimal.ZERO, BigDecimal::add));
}
public static void main(String[] args) {
var original = new ArrayList<>(List.of(new LineItem(new BigDecimal("12.00"))));
var order = new Order(original);
original.clear();
var policy = new PricingPolicy(new BigDecimal("0.80"));
System.out.println(finalPrice(order, policy));
System.out.println(order.items().size());
try {
order.items().clear();
} catch (UnsupportedOperationException expected) {
System.out.println(expected.getClass().getSimpleName());
}
}
}The output is 9.6000, 1, and UnsupportedOperationException. Clearing the source list does not alter the order’s list, and its returned list cannot be modified. The LineItem contains only an immutable BigDecimal, so there is no mutable element hiding inside this example. The extra decimal places come from BigDecimal scale rules; the demo does not round to a currency unit.
Completion Checklist
Choose a method for calculating prices based on members, events, and products:
- List all actual inputs the method reads;
- Identify any external state that it modifies;
- Make time, random numbers, and configuration explicit dependencies;
- Have the core function return a decision instead of writing directly to the database;
- Write three tests that don’t require a mocking framework.
If tests still need to start containers or reset global variables, the pure computation boundary remains unclear.