We spend more time reading code than writing it. Yet, we often write code in a way that is hardest to read.
Traditional imperative programming is a recipe:
It is a sequence of instructions. It forces the developer to act as a CPU, emulating state changes in their head just to understand what the code is trying to achieve.
Consider a more realistic scenario: processing a list of telemetry events. We want to:
This is a multi-step data-transformation task where mutable state can easily become 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.
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:
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.
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:
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.
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.
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.
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:
The result we strive for is a core system that proves to be incredibly stable, painless to maintain, and exceptionally easy to reason about under load.
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 your intent. You make your codebase self-documenting, predictable, and robust. You stop telling the computer how to do its job, and start telling it what you actually want.