Beating the Software Chaos Monster

Simon Baars
8 min readDec 18, 2023

--

Perfectly working software is, regardless of its underlying design, in a state of stability. No matter the technical debt; if it works, it works. When we start modifying the software, it enters a state of chaos: the behavior becomes unpredictable. When making a small change in relatively well-designed software, the path back to stability may be short. For a bigger change, especially in poorly-designed software, the chaos that ensues may devour the software whole.

Introducing: the Chaos Monster. This is not the monster hiding under your bed at night. No, this monster is far more sinister. It comes out of hiding at your most vulnerable moment: the moment you hit compile for that big feature you’ve been working on for days. But you can beat it. And I will show you how.

Programming with checkpoints

The deeper the software goes into its state of chaos, the harder it becomes to bring it back to a state of stability. Although it can be very tempting to program a big feature or apply a large refactoring at once, the debugging stage after implementation may well exceed the time spent on the implementation, and the resulting software might be error-prone. Instead, we should work in micro-increments. These micro-increments differ from Scrum’s increments, in that they do not necessarily create user value or change the software in any functional way. Their main purpose is to keep the software as close to the state of stability as possible.

Working in micro-increments

My favorite style of working in micro-increments is aiming for “atomic commits”. When working on a bigger problem, we consider one thing: what is the smallest next change that I can make to get closer to solving the bigger problem? It’s a sort of Just-In-Time way of breaking large tasks down into small increments: instead of breaking down the entire task, we just let increments arise organically.

When working with “atomic commits”, we implement a small change after which the software is at a state where it compiles and should be largely functional. After finishing it, we commit it.

For me, the biggest advantage of micro-increments is that it makes it easy to take risks. I can attempt a risky change, and if it fails, not much is lost in rolling it back (e.g., reverting to the last atomic commit). Also, when testing the bigger change, if a regression issue is discovered, it is easy to bisect to find the offending atomic commit.

A single commit should never exceed one hundred lines. If any of those lines are faulty, it will become a needle-in-a-haystack exercise to find it.

Using git effectively is a major superpower in Software Engineering. Personally, I like to use a tool called lazygit. It's a great terminal UI for managing git workflows. The biggest advantage of lazygit is that it makes it easy to commit chunks rather than the whole diff. If we get caught up in the flow state and program a 100+ line change, it doesn't mean we have to commit it as a single chunk. Instead, we can dissect it into individual responsibilities, and commit those separately.

TDD

TDD (Test Driven Development) is another way to work in small increments. Before we write the logic, we define small behaviors that we would like the software to have. Different styles of tests are possible, such as regular unit tests or property-based testing. The main goal is to write tests that we can individually implement. So for example, if we write ten tests, we will have ten iterations in which we each make sure that one more test starts passing (also make sure that all tests fail by default!).

Let’s share a little example of property-based TDD with a micro-increments style of implementation.

Say, we want to test a function that creates a multiplication table of the first five products for a given number (for example, multiplicationTable(5) should give [5, 10, 15, 20, 25]). Let's start with the ever-failing implementation in Java.

int[] multiplicationTable(int n) {
throw new IllegalStateException("Not implemented :(");
}

Now we can come up with a set of properties. This requires some big brain time, but we are smart people. Here are some properties I thought of, ordered somewhat from weak to strong:

  1. The result will always have a length of 5
  2. The first number of the output list is the input
  3. Any element modulo the input is zero
  4. The difference between consecutive elements is the input

Let’s implement the first property (assuming a library that generates random input):

void hasLengthOfFive(int n) {
assertEquals(multiplicationTable(n).size(), 5);
}

Let’s make the simplest implementation that passes the test:

int[] multiplicationTable(int n) {
return new int[]{0, 0, 0, 0, 0};
}

Now, let’s create the second property (the first number of the output list is the input):

void firstNumberOfOutputIsInput(int n) {
assertEquals(multiplicationTable(n)[0], n);
}

And change our implementation:

int[] multiplicationTable(int n) {
return new int[]{n, 0, 0, 0, 0};
}

So far so good! Now the third property (any element modulo the input is zero):

void remaindersAreZero(int n) {
assertTrue(IntStream.stream(multiplicationTable(n)).allMatch(x -> x % n == 0));
}

And let’s change the implementation to pass that one:

int[] multiplicationTable(int n) {
return new int[]{n, n, n, n, n};
}

And the last property (the difference between consecutive elements is the input):

void differencesAreInput(int n) {
int[] table = multiplicationTable(n);
assertTrue(IntStream.range(1, n).allMatch(x -> table[x-1] - table[x] == n));
}

And to pass that one, we’ll have to finish our implementation:

int[] multiplicationTable(int n) {
return new int[]{n, n*2, n*3, n*4, n*5};
}

Refactoring in micro-increments

Sometimes in life, we meet a teacher that inspires us way beyond the scope of what they are teaching us. One such inspiring teacher for me is Victor Rentea. At my previous company, he taught several different Java-related courses. Some teachers know their subject matter and try to convey that knowledge to you. Victor, on the other hand, lives and breathes his subject matter. He is completely emotionally involved in writing clean code. And that is awesome.

Well, enough of my fan-boying about Victor. I just want to tell you about a great talk he did about what he called “baby-steps refactoring”. For some concrete examples, I can recommend the articles Refactoring With Baby Steps and Baby Steps in TDD.

If you’re interested to hear about baby-steps refactoring straight from my source, check out Victor’s YouTube video in which he shows a TypeScript example of such refactoring practice.

Creating abstractions

Every year, I like to do a series of programming puzzles called Advent of Code. It has a competitive aspect: there’s a leaderboard with the first people to solve the problems. For me, doing Advent of Code serves as a yearly check-in of my ability to beat the Chaos Monster. As fast as I can, I create a lot of chaos (untested code) and try to reach a point of stability (the correct answer to the problem).

When doing Advent of Code, working in micro-increments, especially something like TDD, is costly in terms of time. Instead, I try to use abstraction as much as possible to reduce the chaos.

As an example, one of this year’s puzzles involved a classic game of poker. The given dataset contained the cards drawn by hundreds of poker players, and the money they bet into the game. The puzzle involved comparing all hands against each other, and calculating how much money each player won by multiplying their bet by the place they got on the scoreboard. The solution was the sum of all money won. My solution, without sharing underlying abstractions, is as follows:

public record Hand(String cards, int bet) {}

private long solve(Comparator<Hand> comparator) {
return zipWithIndex(
dayStream().map(s -> readString(s, "%s %i", Hand.class)).sorted(comparator)
).mapToLong(hand -> hand.e().bet * (hand.i() + 1L)).sum();
}

Here, I use the following abstractions:

  • All hand-comparing logic is moved to the Comparator (separation of concerns).
  • I have a utility zipWithIndex used in place of what would otherwise be a for-loop, because I consider for-loops very non-expressive and prefer the functional style.
  • I have a utility dayStream() that reads the line-separated input for today's puzzle into a Stream of String.
  • I created my own data mapper because I find existing data mappers too complicated. In this example, it reads the String into the Hand record based on a pattern "%s %i" (String [space] Integer).
  • In my opinion, the Java Stream is very expressive: the mapToLong in which the result is calculated and the subsequent sum() make the algorithmic complexity of the arithmetic very low.

Coming up with the right abstractions is mostly something that comes with experience, but there are a few rules-of-thumb:

  • When a function/method has more than ten lines, you’re probably missing an abstraction.
  • When a function/method has more than two parameters, you’re probably missing an abstraction.
  • When a function/method has a boolean parameter, it might not be adhering to the single-responsibility principle.
  • Abstract any accidental complexity (e.g. code that doesn’t express the direct problem) away to other classes/modules.
  • No circular dependencies.
  • (Am I missing important items? Please let me know via a comment!)

Given the example of competitive programming, you might not consider creating abstractions as a low-overhead way of managing chaos. Although usually having no abstractions at all might be faster in theory, creating abstractions doesn’t have to be very time-consuming. You just need to know the IDE shortcuts for extracting things (most importantly: extract method and extract variable), and be fast with naming things.

Embracing the chaos

One thing I would like to emphasize is that the goal is not to reduce the chaos to zero. We just want to make sure it doesn’t consume us. Working in micro-increments and using abstractions can be a low-overhead way to contain the chaos. Having a good test framework and applying consistent design are other ways to reduce chaos, but those come with great engineering and maintenance overhead.

Some projects require more stability than others. When developing critical software on which the lives of millions depend, we need to use all the techniques we can to reduce chaos. When prototyping software that is not business-critical, a little chaos is not so bad. Dealing with a certain amount of chaos is a skill you can hone by fighting the Chaos Monster (e.g., debugging). At times, it can even be fun!

--

--

Simon Baars

Yet another guy making the internet more chaotic with random content.