Notes on

Grokking Simplicity: Taming complex software with functional thinking

by Eric Normand


What is functional programming?

Functional programming is a programming paradigm that uses mathematical functions & aims to avoid side effects by using pure functions.

Side effects are anything a function does other than returning a value. For example:

  • Sending an email
  • Reading a file
  • Blinking a light
  • Making a web request
  • Applying the brakes in a car

Side effects can be problematic, as they occur every time the function is called. So if you need the return value, but not the side effect, you'll cause things to happen unintentionally.

Functional programmers try to avoid unnecessary side effects.

Pure functions are functions that only depend on their arguments and have no side effects. Given the same arguments, they produce the same return value. These can be considered mathematical functions, to distinguish them from language functions.

Functional programmers aim to use pure functions, as they are clearer and easier to control.

It's not true the functional programmers completely avoid side effects and only use pure functions. Otherwise, how would you ever get anything done? We run our software to get side effects. We want to send emails, update databases, make web requests, etc.

Functional thinking is the set of skills and ideas used by functional programmers to solve problems with software.

This book presents:

  1. Distinguishing actions, calculations, and data.
  2. Using first-class abstractions.

Actions, calculations, and data

We classify code into 3 categories.

  1. Actions: depend on when they are called or how many times they are called. E.g. you wouldn't want to call a function that makes a web request twice unintentionally, that could be costly.
  2. Calculations: no matter how many times you call these or when you call them, given the same inputs, they'll give the same outputs.
  3. Data is simply raw data.

You want to separate calculations from actions.

Actions have implicit inputs and outputs. E.g. reading / modifying global variables. Calculations do not have these.

You can often replace implicit inputs with arguments, and implicit outputs by return values.

This makes your code more testable, and you often increase code quality (more separation of concerns, better cohesion, etc.).

Design is about pulling things apart. And a good way to do this is through functions. Functions can help us separate concerns.

We should aim for small, simple functions as they are easier to reuse. They make fewer assumptions overall, which is more maintainable. They're also easier to maintain just because they have less code. And they're easier to test. You just have to test one thing---because they do only one thing.

Stratified design

In stratified design, each layer builds on those beneath it.

Towards the top layers, changes occur frequently. Towards the bottom layers, changes are seldom. The main layers are business rules (top) → domain rules (mid) → tech stack (bot).

Business rules can change frequently, whereas the rules for the domain change less frequently. And finally, the rules for the tech stack rarely changes... e.g. JS objects, which rarely change (not the object instances, but the concept of objects).

By having code that changes frequently on the top layer of our stratified design, we can move faster. It's much slower & harder to change code that other parts of the program rely on. Having to frequently change low-level aspects would mean moving very show.

The longer the arrow (dependency between layers) is (more layers between dependencies), the harder it is to change. If something on the top layer relies on something on the bottom layer directly, then the bottom layer code becomes very hard to change.

Testing the bottom layers is higher ROI than testing those at the top layers.

  1. Code at the top layers change frequently. The tests would have to change frequently, too, then.
  2. Testing bottom-level code means every layer above becomes more reliable, because the bottom layer is more reliable. It's like having a solid base / foundation.

Code at the bottom is more reusable as well. As you move further up, the code becomes increasingly more niche to particular business rules & logic.

And code reuse is great because it saves time and money.

Pattern 1: Straightforward implementations

As noted previously, stratified design uses layers to distinguish between functionality.

The first pattern that is discussed for stratified design is straightforward implementations. An important principle to this pattern is to only call functions from similar layers of abstraction. The presented example showed operations on a data structure (abstracted; e.g. add_item() to add items to the data structure) vs. language features (for loop, for example). These shouldn't be in the same function. I think the point here is that you shouldn't have arrows from e.g. the business rules layer to the bottom layer. Arrows should only have length = 1 (or something).

Straightforward implementation means the problem the function signature presents is solved at the right level of detail in the function body. If there's too much detail, that's a code smell.

Correction on the arrow length: it should be that all arrows are of the same length, not necessarily length = 1. When arrows of a layer are of the same length, you are working at the same level of detail across the layer you're zooming in on.

Pattern 2: Abstraction barrier

The second pattern is abstraction barrier. This is about hiding implementation details. They help us write higher-level code to free up mental capacity.

This is a layer of functions, acting as a barrier that hides the implementation, so you can forget about the lower-level details (e.g. data structures used, etc.).

Basically, provide a simple API that doesn't require knowledge of implementation details of the API internals.

Pattern 3: Minimal interface

This is about finding the minimal interface to cover the necessary operations for important business concepts.

Every other operation should be defined in terms of these.

Pattern 4: Comfortable layers

This is about investing time into getting the layers into a state that helps us deliver high quality software faster. Layers are not added without reason. They should feel comfortable.

But it's also about stopping when you feel comfortable. You can over-design. Optimize prematurely. Spending too much time on this delays actual value-creating tasks for the business, and that's no good. If you are comfortable working with the layers, you can relax. And when you, at some point, feel uncomfortable, you can start applying the patterns again. You're never at some ideal point & stay there. It's like a bounded area you strive to stay within.

Immutability

Copy-on-write

Implementing copy-on-write operations can help with immutability.

E.g. for adding items to arrays: slice the array (to copy), push the item to the array copy, and return the copied array.

Then you avoid modifying the original.

The basic steps of implementing copy-on-write are:

  1. Make a copy
  2. Modify copy as you like
  3. Return copy

Using copy-on-write, you convert write operation into read operations - you never modify the original data.

You can generalize most of these operations, so you don't have to implement copy & return every time you wish to modify something. Take for example this removeItems operation, which is the copy-on-write version of splice:

function removeItems(array, idx, count) {
    const copy = array.slice();
    copy.splice(idx, count);
    return copy;
}

How do you make something that both reads and writes copy-on-write? Take Array.shift for example. You can either split the function into read & write, or you can return two values from the function. The former is preferable. shift shifts an array one to the left, i.e. it drops the 0 index element and then returns it. You can imitate the latter part of that operation by simply returning array[0]. You can convert the dropping of the 0th element to copy-on-write quite trivially as well: make a copy, use shift on the copy, and then return the copy.

And this is what the copy-on-write shift operation returning two values would look like:

function shift(array) {
    const array_copy = array.slice();
    const first = array_copy.shift();
    return {first, array: array_copy};
}

The idea behind all this: turning writes to reads - and reads to immutable data structures are calculations. Which is great! Reads to mutable data are actions, as per the definition of an action.

Okay, so let's address the elephant in the room: performance. Yes. Immutable data structures do use more memory, and they are slower to operate on. However, they are fast enough. The book claims that even some high-frequency trading systems use them, where speed is vital. Some arguments:

  • Be careful of premature optimization. Do you want to trade performance for complexity before even knowing whether the optimization is needed? If it's too slow, you can optimize later.
  • Garbage collectors are fast.
  • You aren't copying as much as you think. Most of the copies shown in the examples above are shallow copies, meaning only references are copied. This is pretty cheap.

You might also think: if we're just copying references, can't it end badly if the elements of the arrays are references? No. Not if the actions performed are use immutability. Structural sharing (two pieces of nested data sharing some references) is safe when it's all immutable. It uses less memory and is faster than copying. By "it's all immutable", I mean that every operation is immutable. This is what makes the operations safe. By copying, then modifying, and then updating references in copied objects, even nested modifications are fine.

What about objects?
Use Object.assign({}, object); to copy objects. Or structuredClone, but that came out after this book, and is much more expensive (does deep copying).

Arrays
So, another way to do copy on write is to use callbacks.

function withArrayCopy(array, modify) {
    const copy = array.slice();
    modify(copy);
    return copy;
}

This reduces code duplication in a ton of functions, where you'd have to do the CoW logic (copy, modify return). This uses the concept of higher order functions and functions as first-class values. I.e. functions returning/taking in functions and functions being passed as e.g. parameters.

Defensive copying

So, how do you deal with codebases that only partially have immutability (with copy-on-write)?

You do defensive copying.

For this, you create two copies of the data. When you get mutable data, make a deep copy and throw away the mutable original (since the untrusted code has a reference to it). And when data leaves your immutable-data-adhering-code, you make another deep copy and send the copy to the untrusted code.

Here's how to implement deep copying in JS.

function deepCopy(thing) {
    if (Array.isArray(thing)) {
        const copy = [];
        for (let i = 0; i < thing.length; i++) {
            copy.push(deepCopy(thing[i]));
        }
        return copy;
    } else if (thing === null) {
        return null;
    } else if (typeof thing === "object") {
        const copy = {};
        for (const key in thing) {
            copy[key] = deepCopy(thing[key]);
        }
        return copy;
    } else {
        return thing;
    }
}

But it isn't complete. structuredClone is better. This just illustrates the principles.

Higher-order tools

Functional iteration chapter covers map, filter, and reduce. The book provides a fantastic explanation of them, which isn't included in these notes.

The book presents some functional tools in JavaScript, e.g. pluck, concat, frequencies, and groupBy. I've converted them to typescript here:

function pluck<T, K extends keyof T>(arr: T[], key: K) {
    return arr.map((item) => item[key]);
}

function concat<T>(arr: T[][]): T[] {
    return arr.reduce((acc, item) => {
        acc = [...acc, ...item];
        return acc;
    }, []);
}

function frequenciesBy<
    T extends Record<string, unknown>,
    U extends T[keyof T] & (string | number | symbol)
>(arr: T[], f: (item: T) => U): Record<U, number> {
    return arr.reduce((acc, item) => {
        const key = f(item);

        acc[key] = (acc[key] ?? 0) + 1

        return acc;
    }, {} as Record<U, number>);
}

function groupBy<T extends Record<string, unknown>, K extends T[keyof T] & (string | number | symbol)>(arr: T[], f: (item: T) => K) {
    return arr.reduce((acc, item) => {
        const key = f(item);

        if (!Object.hasOwn(acc, key))
            acc[key] = [];

        acc[key].push(item);

        return acc;
    }, {} as Record<K, T[]>)
}

Chaining functional tools

Basically, this chapter (chaining functional tools) presented how to combine the functional iteration tools from the previous chapter. You combine them into a chain, e.g. arr.filter(...).map(...).

Also showed how you can use reduce to build a data structure. In this case, it looked like they used event data (adding / removing items to / from a shopping cart) to build a shopping cart DS. Array was an array of [['add', 'shoes'], ...] which let the reduce build the cart from that. Supposedly, this is called event sourcing.

Dealing with nested data

Presents functional tools for nested data (e.g. objects/hash maps).

First, update. Takes Record<string, unknown> (object), string (field name), and a function to modify the value. It starts by grabbing the value in the object, i.e. const value = obj[field]. Then it calls modify (callback fn) on value. Lastly, it uses objectSet to return a new object with the value set (for immutability; CoW).

But obviously, update doesn't work on nested data. So here's nestedUpdate():

function nestedUpdate(object, keys, modify) {
    if (keys.length === 0)
        return modify(object);
    const key1 = keys[0];
    const restOfKeys = drop_first(keys);
    return update(object, key1, function(value1) {
        return nestedUpdate(value1, restOfKeys, modify);
    });
}

Handling async tasks

The book provides a nice & concise description of the JavaScript event loop and job queue.

Jobs are added to the queue in the order the events happen. The queue ensures jobs are handled in the order they come in (FIFO). When you call asynchronous operations, the callback is added to the queue as a job. The event loop takes the front job from the queue and executes it to completion(!), then grabs the next. This means all your code is run in the event loop thread.

One of these chapters also present a queue which facilitates task running in order. The queue helps us share resources safely across timelines. I've built a TypeScript implementation:

function Queue<
    TData,
    TCallback extends <T>(arg?: T) => void,
    TItem extends { data: TData; callback: TCallback },
    TWorkerFn extends (data: TData, done: TCallback) => void
>(worker: TWorkerFn) {
    const queueItems: TItem[] = [];
    let working = false;

    function runNext() {
        if (working) return;
        if (queueItems.length === 0) return;

        working = true;
        const item = queueItems.shift() as TItem; // queue length is checked above. This cannot be undefined.

        worker(item.data, function (val) {
            working = false;
            setTimeout(item.callback, 0, val);
            runNext();
        } as TCallback);
    }

    return function (data: TData, callback: TCallback) {
        queueItems.push({
            data,
            callback,
        } as TItem);

        setTimeout(runNext, 0);
    };
}

They also present a DroppingQueue. This fixes the issue of, when queue items take a long time, but write over each other, then you only want to keep the latest item. So you pass a max to the queue, which it uses to drop items until there's only that many items left in the queue. I.e. put a while (queueItems.length > max) queueItems.unshift() in the returned function, after adding the new item to the queue.

Reactive & Onion Architectures

Reactive

Chapter Reactive and onion architectures present the two architectures.

Now, I'm particularly interested in the reactive architecture. You structure your application & its logic such that events facilitate actions. "When X happens, do Y, Z, A, and C."

This is particularly interesting to me, because it's how the web is currently converging on UI frameworks. React, for example... it's in the name! But also in how we manage our state. For example, later in this chapter, they present a ValueCell method of handling data & updates to them. In other words, first-class state management.

Here's a TypeScript implementation:

function ValueCell<TData, TWatcher extends (value: TData) => void>(
    initialValue: TData
) {
    let currentValue = initialValue;
    const watchers: TWatcher[] = [];

    return {
        value: () => currentValue,
        update: (f: (oldValue: TData) => TData) => {
            const oldValue = currentValue;
            const newValue = f(oldValue);

            if (oldValue !== newValue) {
                currentValue = newValue;

                watchers.forEach((watcher) => watcher(newValue));
            }
        },
        addWatcher: (f: TWatcher) => {
            watchers.push(f);
        },
    };
}

The name, ValueCell comes from spreadsheet applications. A value cell just has a plain value, as opposed to formula cells, which calculate derived values, based on value cells.

Here is a TypeScript implementation of the FormulaCell:

function FormulaCell<
    TCell extends ReturnType<typeof ValueCell>,
    TData extends ReturnType<TCell["value"]>
>(upstreamCell: TCell, calc: (value: TData) => TData) {
    const myCell = ValueCell(calc(upstreamCell.value() as TData));
    upstreamCell.addWatcher(function (newUpstreamValue) {
        myCell.update(function (_) {
            return calc(newUpstreamValue as TData);
        });
    });

    return {
        val: myCell.value,
        addWatcher: myCell.addWatcher,
    };
}

Equivalents of these are seen in many languages & frameworks. In React, we have Redux stores & Recoil atoms, for example.

Why the reactive architecture is good

  1. Decouples cause and effect; by using the observer pattern, you don't have to call various elements where they don't belong.
  2. Treats steps as pipelines. Data flows naturally through, like with promises in JS. There are frameworks that can help, e.g. RxJs.
  3. Creates timeline flexibility (if desired). The timelines are shorter, and there are no shared resources between them. This prevents many common issues, e.g. race conditions, etc.

Onion

The onion architecture is a set of concentric layers:

  • (innermost) Language layer, for language and utility libraries.
  • (middle) Domain layer, for calculations that define the rules of your business.
  • (outer) Interaction layer, for actions that are affected by or affect the outside world.

This is not set in stone. The layers don't need to be exactly like that, but onion architectures generally follow these three large groupings.

Rules:

  1. Interaction with the world is done exclusively in the interaction layer.
  2. Layers call in toward the center.
  3. Layers don't know about layers outside of themselves.

This presents an idealized view. It may not always be good to chase that kind of ideal. Sometimes you have to make tradeoffs between conforming to such principles, and real-world concerns.

Actions can be more readable than a calculation counterpart. There are nuances—reasons to pick one over the other—nd we must be aware of that. Readability, development speed, system performances. These are some nuances that can make you bend the rules a bit. And that can be OK, too.

The guidelines can help you recover when things get a bit sour from technical debt.

Liked this post? Join the newsletter.