~/ ~/documents ~/software ~/pictures github (opens in new tab)

Mutation is the Enemy of Reason

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:

  1. Filter out events that aren’t errors.
  2. Group the remaining error events by their service name.
  3. Compute the average latency for each service’s errors.

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.


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:

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:

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 nginx

If 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:

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.


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 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.