Max Sadrieh

Flexibility is complexity

Software built to be flexible must pay for that flexibility with commensurate complexity. The more use cases you try to solve, the less straightforward what you engineer tends to be.

Striving for reuse and generalized solutions — which typically involves making modules and systems abstract rather than concrete — is usually a bad tradeoff to make.

Let’s see why.

Entry points and paths

Every software system has one or more entry points. The entry point is the public interface of the system, how it interacts with the outside world.

Once the caller invokes your system by using the public entry point, the execution takes one of a number of paths through its internals.

This leads us to a key question when trying to design software systems.

How many possible logical paths are there, given the set of all possible arguments, and why does it matter?

Concrete examples of increased flexibility

In the simplest possible case, there is exactly one path. For example, the following TypeScript function has a single path — from top to bottom — and a single outcome — returning the string “Hello”.

Example 1
function getGreeting() {
return "Hello" as const;
}

On the other hand, the following code has two paths and two outcomes:

Example 2
function getGreeting(
timeOfDay: "day" | "night"
) {
if (timeOfDay === "day")
return "Hello" as const;
return "Good night" as const;
}

The presence of a conditional makes the code branch into two distinct paths.

Loops can also add paths to your code, if the loop variables are determined in part by the inputs to your system. Other constructs can behave like conditionals (such as objects keyed using arguments) or loops (such as recursion), and they too create diverging paths.

The number of paths and outcomes do not necessarily match and neither do the number of paths and input values. For example, this piece of code has a single path (from top to bottom), but an infinite number of possible input values and outcomes (the set of all possible strings, and the same set prepended with “Hello”, respectively):

Example 3
function getGreetingWithName(
name: string
) {
return `Hello ${name}`;
}

As we’ll see below, we care about limiting the set of all outcomes (the range), inputs (the domain), and paths, but it’s the number of paths that’s particularly costly and I’ll focus on it most.

Making it even more useful and reusable

How useful is our first example? Not very. We always return the exact same greeting. We might as well inline that string wherever we’re using it (or use a constant). We’re very unlikely to reuse it anywhere.

The second example is significantly more useful. It encapsulates some logic that can be used in multiple different cases. What makes it useful is the ability to return a different value depending on the function’s argument.

Let’s take this to an extreme. What about the following example:

Example 4
function getGreeting(
timeOfDay: "day" | "night",
language: "en" | "fr",
formality: "high" | "low"
) {
if (language === "en") {
if (timeOfDay === "day") {
if (formality === "high")
return "Hello" as const;
return "Hi" as const;
}
return "Good night" as const;
} else {
if (timeOfDay === "day") {
if (formality === "high")
return "Bonjour" as const;
return "Salut" as const;
}
return "Bonne nuit" as const;
}
}

In a sense, it’s even more useful: it encapsulates a large number of concepts and provides the caller with a lot of possibilities.

But is it better? Intuitively, it seems way more complex than it ought to be, and indeed in most cases, it is. Trying to abstract that complexity away — which I’ll touch upon below — does help somewhat, but it remains significantly more complex than our example with only two paths.

Do we need this flexibility, or would we be happy with less power?

Mutability, randomness, and input: mo’ power, mo’ problems

So far, all of our examples have been completely deterministic based on the arguments to the function. However, this isn’t always the case.

Example 5
function logThreeTimes() {
let logCount = 0;
return function maybeLog() {
if (logCount < 3) {
logCount++;
console.log("Hi!");
}
};
}

This example uses a closure to read and mutate the logCount variable, but the same concept applies to object properties or global variables.

When something in your system is mutable, its value can change over time, typically to represent the updated state of the system. This allows your system to modify the paths and outcomes of its routines in more cases: whereas before the path was fully determined by the inputs, it now also depends on the state. To predict the path, you need to be aware of all the previous operations that may have affected the state.

Input/output and randomness lead to the same problem, with the added problem that they make predicting paths and outcomes virtually impossible, even knowing all the previous operations within the system. For example:

Example 6
async function getGreeting(
userId: number
) {
try {
const response = await fetch(
`/user/${userId}`
);
if (!response.ok)
throw new Error(
"Failed to fetch"
);
const payload =
await response.json();
const name = payload.name;
if (!name)
throw new Error(
"Failed to get name"
);
if (typeof name !== "string")
throw new Error(
"Name had wrong type"
);
return `Hello ${name}!`;
} catch {
return `Hello!`;
}
}

This seemingly simple little snippet of code hides a surprisingly large number of paths, which it tries to coalesce into two return statements.

  1. The fetch call may throw, if there is a network error (for example if we’re offline)
  2. The HTTP response status may be unexpected, such as a 404, in which case we throw
  3. The HTTP body may not be valid JSON, leading the parsing step to throw
  4. The JSON payload may be null, in which case accessing the name property will throw
  5. The name property may be of the wrong type or missing, in which case we throw
  6. Finally, everything may work as expected (the “happy path”), in which case we return the expected greeting. In all other cases, we return a default value.

There are two aspects of external input that force us to multiply paths: it sometimes fails (such as network errors) and it’s hard to enforce a bound on the set of values, leaving us with few formal guarantees. Outside of external input, which is by nature unpredictable, that problem can be addressed with types. External input can also use types, through the use of a schema (such as the one used by GraphQL, or Thrift/Protobuf).

State, input/output, and randomness can be necessary to accomplish useful tasks: most computer systems need to store data somewhere, whether that be on disk, remotely through an API, or in a database. Indeed, the simple fetch example above cannot easily be made any simpler: most of the paths are unavoidable. However, here as well the price of this usefulness is increased complexity.

The costs of power

Predictability and tractability

Now that you’ve seen a full range of flexibility, from extremely constrained to fully unpredictable, why should you lean towards the former?

Every software system has some amount of intrinsic flexibility. A function or program that always returns the same value is only useful as part of a greater composed whole.

However, there is a tradeoff between how useful and reusable a system or a piece of code is and how simple it is. Our industry tends to valorize abstraction and reusability, but, for typical systems, those should rarely be primary architectural goals.

If you’re lucky, the code you write will have long and useful lives in your system. If so, this means they’ll be read, debugged, and tweaked for your initial use case way more than they’ll be reused.

Now imagine the worst example I’ve given multiplied by the number of functions in a reasonably large system. Stepping through something like that — something that can go any which way — quickly becomes a nightmare, whether that’s in your mind (statically) or with a debugger (dynamically).

Lack of constraints and illegal states

Flexibility can also sometimes lead you to allow your callers to represent illegal states. Take this function for example, which is meant to map day of the week indices to the corresponding abbreviation in English:

Example 7
function dayNumberToAbbr(
idx: number
) {
if (dayNumber > 6)
throw new Error(
"Invalid day number"
);
return [
"Mon",
"Tue",
"Wed",
"Thu",
"Fri",
"Sat",
"Sun",
][idx];
}

In this example, passing a day number greater than 6 is invalid, but we failed to constrain the types enough to encode it, forcing us to throw. In TypeScript, the proper fix is to use a union type to limit the domain of our function.

Some practical advice to lower your costs

What does this teach us about how we should engineer our systems?

Limit paths in your code

Strive for single, linear paths in your code.

You’ll find the same advice elsewhere in a multitude of other forms: “Keep it simple, stupid”, “You ain’t gonna need it”, the Unix philosophy. All these boil down to the same thing: limit the possible paths and outcomes of your system to the strict minimum needed to solve the problem. Resist the temptation to abstract too early — or at all!

Completely novice engineers tend to stay concrete in the extreme, putting everything in gigantic monolithic functions. But slightly more experienced engineers — once they’ve been taught the abstraction tools of their programming paradigm and in particular those liable to read advice on how to write “clean” code — are much likelier to make the opposite mistake and abstract too much. Resist that temptation.

In practical terms: remove parameters in your REST endpoints, flags in your functions, subclasses, multiple implementations of interfaces, etc. Make it straightforward and concrete.

Decide where you’ll go early (and once)

To the extent that you need to branch to different behaviors, do so as early as possible in the execution, and do it once and for all. Ideally, you want to parse and analyze the input and schedule the steps you’ll have to take as soon as your code starts executing.

If you coalesce all of the decisions into a single spot, you make it easier to reason about. When reading or debugging the rest of the code, there’s only a single path to keep in mind.

Encapsulate branches into abstractions

One practical way to decide where you’ll go early is to use your programming paradigm’s tools for decision abstraction.

In object-oriented programming, you can use a common interface and subclasses initiated at a high level. For example, you could initiate a different parser for Markdown files and AsciiDoc files in the entry function of your program depending on command line arguments and pass that instantiated parser to the rest of the program. (An extension of this idea is to use the “strategy pattern”.)

In functional programming, you can typically use different functions you pass as arguments to specialize the behavior (they are, after all, first-class values). An example of this behavior is the filter function, which is straightforward itself, with one single path, but specializes by accepting a lambda function to encapsulate the decision to be made about each item of the collection. An alternative is monads, like Maybe, or in TypeScript and JavaScript, the built-in monad-ish Promise.

Use types to constrain you, in a good way

You may have noticed that the examples I gave you so far used arguments with specific types. These types often determine how flexible and powerful your code is: the more numerous the arguments and the larger their domain (the set of values they can take), the more paths you tend to open up.

This might seem to be specific to statically typed languages, like TypeScript, but it’s not. For example, the following JavaScript code expects three possible types, even though they’re implicit from the body of the function:

Example 8
function isOne(value) {
if (value === 1) return true;
if (value === "one") return true;
return false;
}

(This code uses distinct and verbose returns to highlight the three outcomes.)

What are the three “types”? The number 1, the string "one", and everything else. This gives us three possible paths through our code. (Just like paths and outcomes are not tied, paths and input ranges aren’t either, but they are correlated.)

TypeScript lets us use sum types to restrict the domain of the idx variable, but many statically typed non-functional languages do not. Even for those that do, when the function (or CLI, or system) has more than one parameter, conflicts between arguments that are otherwise in the correct domain can arise.

Simplify, simplify, simplify

I hope I’ve convinced you that the power of flexibility comes at a high cost. As the number of possible paths through your system branch out from the entry point like the edges of a binary tree, it’s easy to find yourself in a completely intractable situation with an exponential number of possible paths to consider. This tendency to over-abstract is a problem many of us struggle with.

With the tips and tools we’ve gone over, you’ll be able to start breaking the complexity cycle. Remember, for a healthier codebase: reduce (paths), constrain (outcomes), and abstract (decisions), in this order.

Go forth and simplify!