I was thankful for jump-to-definition and jump-to-references, each bound to a keystroke in my IDE. But I was reaching the limits of my mental stack. I must have been 10 calls deep before it was hard to keep track of where I was. After about 20, I realized I should have kept notes. I basically had to start over.
I eventually fixed the bug in this existing codebase, but I was exhausted. Tracing the call stack to understand what was going on and where the bug was taxed my mental capacity. When I looked back over the code I traced, from the entry point to the final call where the bug was, I was a little annoyed with what I found—many useless one-liners.
Many of the functions were one-liners. Many of those did nothing more than call two functions on their argument like this:
(defn- foo [a]
(baz (bar a)))
Another set of the one-liners added a conditional check, like this:
(defn- maybe-foo [a]
(when a (foo a)))
And then there was a final set that recombined multiple arguments:
(defn- foo [a b]
(bar (transform b) a))
This isn’t an exhaustive set. I bring up these three types merely to give a flavor of what I was looking at.
Now, you might also be surprised to learn that many of these one-line functions were only used in one place each. Luckily, they were mostly defined as private (using defn-
), so they weren’t advertising to any code outside the namespace to use them.
It got me thinking about these one-liners. In this case, they definitely made the code harder to read. Seriously, does this function add anything:
(defn- datetime? [x]
(= "datetime" (type-of x)))
You might say, “yes,” that it is giving a cleaner interface to the value. Why muddy your code with “magic strings” and have to “manually” call type-of
each time. I know how good it feels to pull out these little helpers, especially when I’m in flow, and especially when I’m writing the code. When I’m in flow, I can mentally hold a much richer graph of function calls (each with their own complex contracts). Each little helper captures some tiny sliver of meaning—and the graph feels more meaningful because of it.
But that mental graph fades quickly. When I return to the code, I cannot see what’s going on anymore. I have to do a depth-first search, often dozens of calls deep, to figure out what’s going on. In general, these one-liners are not worth it. They’re great for writeability but not for readability. For example, datetime?
is really great for writing a one-line filter:
(filter datetime? columns)
It feels so good when we write these lines that we forget to take a look a the bigger picture.
Writing my book about domain modeling has given me a much deeper appreciation of why these functions don’t work. In theory, they are good. They’re simple. They do one thing. They’re short. They’re each easy to understand. But the real killer for them is that they don’t help recover the design. In fact, they usually obscure it.
I’ve been working with this idea in my new drafts that an important function of software design is to make sure the model is evident in the code. This tiny example might not seem to obscure the public function type-of
, but consider that there are three levels of indirection between the entry point and the final call to type-of
. You wouldn’t know it’s an important function without digging through the call tree to the bottom.
But this doesn’t damn all single-line functions. Being able to do stuff on a single line is also a signal that you’ve got the model right. Things just easily compose together. So when does it make sense?
Well, like all design decisions, the answer is really complex and context-dependent. I can’t give you hard and fast rules. But there are signals to look for.
Is the function non-obvious? That is, could the implementation be meaningfully different? Is this part of your business’s secret sauce? For instance, this one-line function is probably worth keeping:
(defn coffee-price [coffee]
(+ (size-price (size coffee)) (add-ins-price (add-ins coffee))))
We can imagine a different coffee shop calculating the price of their coffees differently. This function captures what our company means by the price of a coffee. It’s probably worth having that written down in one place and given a name.
Is the function used in multiple places? If it is, it’s probably an important concept that belongs to your domain. Instead of obscuring things, it’s actually illuminating meaning.
Does the name of the function add meaning? The name of the function anchors the function in human meaning. If the function simply restates what the body of the function states (like datetime?
does), it’s probably not worth it. Even the function type-of
might have problems in this area:
(defn type-of [x]
(:type-of x))
Uh-oh. Not much meaning added there, is there? That one is probably not worth it, either.
Besides not restating the body, the name should also be phrased in domain terms. If you can’t come up with a domain term (hint: ask why the function should exist), it’s probably not a good function. For example, it might be fun to write a function like this:
(defn ->latte [coffee]
(add-add-in coffee :milk))
It converts a coffee to a latte. But wait, is that actually a thing? Do people come in asking to convert coffees to lattes? Or do they just order a latte? The barista knows, and they say, no conversion. That’s not a thing. But latte is the most popular beverage, so it would be nice to have a shortcut in the UI to add a latte to an order. Bam! That’s meaningful:
(defn make-latte []
(add-add-in (make-coffee) :milk))
Still a one-liner, but meaningful since it is helpful to the barista.
I’m reminded of John Ousterhout’s “deep” vs “shallow” modules. He also rails against having lots of tiny function that do very little. And his idea has the ring of truth. But this rule of thumb felt a little too style-focused for me. It focused too much on the code (size of the interface vs complexity of the implementation) and not enough on the domain (does the interface make the model recoverable?). I am seeking a similar law that relates correctly designed code to the domain.
I’m still reeling from the spelunking through code, trying to make sense of these functions. While small, composable pieces usually signal good design, too many layers of indirection can harm code readability. We need to choose layers that really matter—the ones that illuminate the model. Is this just the way codebases get as they get refactored out over time? I don’t think so. I hope that over time, codebases become clearer as the programmers learn to express the model better in code. All we need to do is to step back and see a bigger picture.