Mutation is the Enemy of Reason
Imperative code forces you to act as a CPU. You don’t read what the program does; you emulate state transitions in your head until you lose the thread.
It is just a sequence of clock ticks:
- Set this variable to zero.
- Loop through this array.
- If a condition is met, mutate the variable.
- Return the variable.
Take a concrete problem: processing a list of telemetry events. We want to:
- Filter out events that aren’t errors.
- Group the remaining error events by their service name.
- Compute the average latency for each service’s errors.
This is a multi-step data-transformation task where mutable state quickly becomes a tangled web.
The Imperative Approach
use std::collections::HashMap;
struct Event {
service: String,
is_error: bool,
latency_ms: u32,
}
fn process_events_imperative(events: Vec<Event>) -> HashMap<String, f64> {
let mut grouped_errors: HashMap<String, Vec<u32>> = HashMap::new();
for event in events {
if event.is_error {
let latencies = grouped_errors.entry(event.service).or_insert_with(Vec::new);
latencies.push(event.latency_ms);
}
}
let mut average_latencies = HashMap::new();
for (service, latencies) in grouped_errors {
let mut total = 0;
for latency in &latencies {
total += latency;
}
let avg = total as f64 / latencies.len() as f64;
average_latencies.insert(service, avg);
}
average_latencies
}To understand this imperative logic, your brain must continuously
play compiler. You track mut grouped_errors,
mut average_latencies, the entry API mutation, nested
loops, helper index pointers, and accumulating mut total.
The code is a series of state alterations. It is fragile, prone to
division-by-zero if an empty slice slips through, and hard to read.
The Declarative Approach
use std::collections::HashMap;
struct Event {
service: String,
is_error: bool,
latency_ms: u32,
}
fn average(latencies: &[u32]) -> f64 {
latencies.iter().sum::<u32>() as f64 / latencies.len() as f64
}
fn group_by_service(mut acc: HashMap<String, Vec<u32>>, event: Event) -> HashMap<String, Vec<u32>> {
acc.entry(event.service)
.or_default()
.push(event.latency_ms);
acc
}
fn process_events_declarative(events: Vec<Event>) -> HashMap<String, f64> {
events
.into_iter()
.filter(|e| e.is_error)
.fold(HashMap::new(), group_by_service)
.into_iter()
.map(|(service, latencies)| (service, average(&latencies)))
.collect()
}Here, the data flows through a clean, unified pipeline:
filter -> fold -> map ->
collect. There is no global step-by-step state
orchestration.
By abstracting key stages into pure, named functions like
group_by_service and average, we decouple the
operations from the pipeline shell itself. This lets us test the
grouping and math calculations independently, compose them at will, and
maintain an extremely readable main sequence. Each step produces a new
layout, letting you reason about the transformations in isolation.
Functional, declarative programming takes a different path. It describes what the result should be, rather than listing the instructions to produce it.
Here is why that is a better default.
1. Reducing Cognitive Load
When reading imperative code, you have to track state. Every mutable variable is a moving part. The more moving parts, the harder it is to keep the entire system in your head.
In a declarative paradigm:
- State is immutable.
- Functions are pure.
- Expressions evaluate to values.
Since values cannot change underneath you, you can reason about a piece of code in isolation. You don’t need to know what ran before it or what will run after it. A pure function will always yield the same output for the same input.
2. The Illusion of Control
Imperative programming gives you fine-grained control over how things happen. But most of the time, you don’t actually care about the how; you care about the what.
By micro-managing execution, you inherit a class of bugs that simply do not exist in declarative systems:
- Off-by-one errors in loops.
- Race conditions from shared mutable state.
- Null-pointer exceptions.
- Order-of-execution bugs.
When you declare the target state, you let the compiler, runtime, or engine handle the translation to instructions. They are much better at optimizing and executing than we are.
3. Declarative Systems: Beyond Code
This philosophy is not limited to software development. It scales to infrastructure and system configuration.
Consider how we configure servers.
The Imperative Script
#!/usr/bin/env bash
apt-get update
apt-get install -y nginx
cp ./nginx.conf /etc/nginx/nginx.conf
systemctl enable nginx
systemctl start nginxIf this script fails halfway through, perhaps because the network
drops during apt-get update, your system is left in an
inconsistent, half-configured state. Re-running it might yield errors
because files already exist or ports are already in use.
The Declarative Configuration (e.g., NixOS)
services.nginx = {
enable = true;
virtualHosts."dominicegginton.dev" = {
enableACME = true;
forceSSL = true;
locations."/" = {
root = "/var/www/dominicegginton.dev";
};
};
};This configuration states what should exist. The underlying operating system engine is responsible for making it happen. If you run it once, twice, or a thousand times, the outcome is identical, clean, and predictable.
The imperative approach is fragile. If a command fails halfway through, the system is left in an inconsistent, unknown state. Re-running the script might break things further.
The declarative approach is reproducible. Because you define the target state, the system can determine the delta, apply the changes safely, or build the entire state from scratch cleanly. This is why tools like Nix, Terraform, and Kubernetes are so successful. They apply functional programming principles to the real world.
4. Composition Over Mutation
In functional programming, we build complex systems by composing small, single-purpose functions. In declarative configuration, we build complex environments by composing modular attributes.
This makes software modular by default. It is easy to test, easy to refactor, and easy to delete.
Case Study: Authorization in Practice
A colleague and I built a core authorization service designed entirely on purely functional principles.
Authorization is a high-stakes domain. It sits in the hot path of every request, requiring extreme reliability, absolute predictability, and strict security boundaries. Any race condition, unhandled null value, or unexpected side-effect is a potential security vulnerability.
By committing entirely to a pure functional model we achieved:
- No Side-Effects: Request evaluations are pure mathematical functions. Given a specific user context and policy, the output is always identical and entirely deterministic.
- Zero Race Conditions: The service operates with completely immutable data structures. We eliminated shared mutable state, rendering concurrency issues impossible.
- Trivial Refactoring: Because the business rules are composed of pure, independent functions, we can restructure, optimize, or swap out parts of the engine with little fear of breaking unrelated features.
- Fearless Verification: Unit testing became trivial. We do not need complex mock frameworks or state setups to assert correct behavior. We simply pass inputs to functions and verify outputs.
The result is a core system that does not drift, is painless to maintain, and exceptionally easy to reason about under load.
Choosing the Better Default
Imperative code isn’t useless. Sometimes you need to squeeze out every drop of hardware performance, or interact with an inherently stateful hardware interface.
But it shouldn’t be your starting point.
When you write declarative, functional code, you are documenting intent. You make the system predictable and robust. Stop telling the computer how to fiddle with memory; tell it what you actually want.