A mistake more expensive than ‘null’
Every day we use a programming pattern that makes software needlessly expensive to build and maintain. It causes countless bugs and security vulnerabilities. It needs constant refactoring. It’s difficult to test, it’s tedious to document, and it’s flexibility makes every implementation a unique snowflake that leads to unending code duplication.
It’s the function.
More specifically, it’s the interface we expose which is commonly a collection of functions.
One of the first things we learn when programming is how to reuse logic. This invariably leads to the function, the building block of all software. Functions aren’t bad all on their own, but relying on them as the primary reusable component is the reason why software is so expensive to write, maintain, and scale.
It is difficult to write reusable code.
No, that’s not right. That doesn’t sit well.
I̶t̶ ̶i̶s̶ ̶d̶i̶f̶f̶i̶c̶u̶l̶t̶ ̶t̶o̶ ̶w̶r̶i̶t̶e̶ ̶r̶e̶u̶s̶a̶b̶l̶e̶ ̶c̶o̶d̶e̶.̶
It is difficult to reuse code.
Well that’s not it either. Take a look at the node.js library
repeat-string on npm. It does nothing more than repeat a string and developers download it over seventeen million times every week.
Seventeen million is only the download count. It doesn’t even begin to illustrate the scope of how many times that function is actually reused in source code. It must be astronomical!
So what am I getting at?
How would you find a module like
repeat-string for your node.js project? You’d search for “repeat string” on npm, right? Maybe you’d search for “string repeat” but the results would be similar. You can see the problem I described above in the second search result. And the fourth. And the ninth. And tenth. And eleventh.
Take a look at these examples. Each library provides the same exact behavior.
Do you see the problem?
"A".repeat(5)). It's that each library has differences across:
- Their input signature. Some take
(string, int), others take
(int, string). One actually accepts floating point numbers and another needs a callback.
- Their output signature. Each output a string except one which returns nothing and delivers its result in a callback. Don’t even get me started on their errors.
- Their execution behavior. One is async others are synchronous.
- How they are exposed. Some expose a single exported function, others expose it as a method on an object.
What’s the big deal?
The problem is these choices add zero value, only friction. They have no impact on “business logic” (a.k.a. the stuff that matters). They are implementation details. Sometimes these choices make up for deficiencies in a language or framework and sometimes they’re just the result of a mental flip-of-a-coin. We try to choose the path that improves the developer experience now or in the future, but predicting the future is impossible. These decisions are diabolical in the way that every choice feels good at first, while everything works. We get a dopamine hit and feel like kings and queens. Masters of all things computery! When we need to add, replace, or change something though, we realize we weren’t quite the geniuses we thought.
How often do we need to change something? Every day. That’s all we do. How often does software break because of it? Literally all the time. Sometimes the break is minor and we fix it without thinking. We accept that broken software is normal. We even write automated tests that do nothing except point out something broke.
The interface friction problem stems from the countless junctures where a programmer can make a decision during development. Decisions like positional arguments vs configuration vs builders, async vs sync, global vs local, stateful vs stateless, static vs instance, constructors vs factories, callbacks vs futures, functional vs object oriented, events vs anything and infinitely many more. Each choice also has a unique flavor for that day’s snapshot of best practices and each is a grain of sand in the machinery.
This frustration isn’t novel, yet we tolerate it and teach it to each generation. Why? It’s not that we make bad decisions, it’s that we have bad choices. Every option is a kaleidoscope of tradeoffs and the right answer looks different when viewed from any other angle. When everything is a compromise, there is always a better choice. There is always a better way to rewrite code.
Let’s refactor the problem statement a third time.
I̵t̵ ̵i̵s̵ ̵d̵i̵f̵f̵i̵c̵u̵l̵t̵ ̵t̵o̵ ̵w̵r̵i̵t̵e̵ ̵r̵e̵u̵s̵a̵b̵l̵e̵ ̵c̵o̵d̵e̵.̵
̵I̵t̵ ̵i̵s̵ ̵d̵i̵f̵f̵i̵c̵u̵l̵t̵ ̵t̵o̵ ̵r̵e̵u̵s̵e̵ ̵c̵o̵d̵e̵.̵
It’s difficult to write interchangeable code.
It’s not catchy, but it’s getting closer.
Without plug-and-play code, we need to iron out every wrinkle in an interface before we can use it. We have to customize every bit of code that goes into an application. Every input. Every output. Every API. We put builders on adapters and wrap them in factories behind services. No amount of lipstick can make your design pattern beautiful.
It is like we’re building software in the 18th century. We cut trees by hand to boards with arbitrary dimensions. We craft hammers from scratch and press iron into nails just to build a house identical to the one next door. It costs too much and takes too long. Even once finished, we’re still burdened with bespoke maintenance. Our dimensions are non-standard, the wiring keeps sending electricians to the hospital, and the builders fresh out of trade school don’t like the way we made nails. We’re overdue for a software Home Depot and digital 2x4s.
It’s not far-fetched to think we could have standards that work well enough for everyone, where everything just fits. Microsoft introduced COM in the 90s to share logic across applications written in any language. The JVM is almost as old and has shown how multiple languages can target the same bytecode and interoperate with each other. The world has been discovering and rediscovering Flow and Linda for decades as a way to connect distributed black boxes, and Docker has revisited what a modern black box can be.