Writing simple code, or the art of maximising the amount of work not done
As I outlined in my previous post, the so-called principles of SOLID are not principles at all. I actually don’t believe it’s possible to create a universal set of principles for all contexts and domains, so I’m not going to try. Instead, I’m going to outline some practices which have helped me when writing websites and web-services. I call this approach simple code. (I certainly didn’t come up with the name.)
What is simple code?
The first source of inspiration for simple code comes from the agile manifesto:
Simplicity - the art of maximizing the amount of work not done - is essential.
In this post, I discuss what this means on an architectural level:
- The implementation should be the simplest thing that works for your use-case
- Decisions should be deferred to the last responsibe moment
- Every dependency, class, and layer of indirection should be justified by a concrete problem
- Code should be refactored continuously
The simplest thing that works
My second source of inspiration is Gall’s law:
A complex system that works is invariably found to have evolved from a simple system that worked. The inverse proposition also appears to be true: A complex system designed from scratch never works and cannot be made to work. You have to start over, beginning with a working simple system.
When implementing a feature, there’s always a temptation to ‘future-proof’ it based on what we think the requirements will be in several months/years. Gall’s law tells us that this temptation should (almost) always be resisted, because we simply don’t know what our requirements are going to be in future.
Let me give an example. A friend of mine worked for a London-based tech company, which shall remain nameless. They spent several years building abstractions into their platform that allowed their customers to plug in their own SQL databases, and supported multiple database systems. In the end, changing market conditions meant they pivoted to a SaaS model, with their own hosted database, and all of these abstractions became useless, unnecessary complexity. Had they simply built what they needed at the time to fulfill their business requirements, they would have launched much sooner, and consequently started to get real customer feedback much sooner.
In web development, there’s a strong tendency towards over-engineering, gold-plating and future-proofing for imaginary futures. Such systems are almost impossible to scale if the real-world usage deviates even slightly from the pre-conceived plan. Building software this way makes it much harder to follow another important agile principle:
Welcome changing requirements, even late in development. Agile processes harness change for the customer’s competitive advantage.
Which brings us onto…
Defer decisions to the last responsible moment
The book Lean software development: an agile toolkit advocates the following approach to writing software:
Concurrent software development means starting development when only partial requirements are known and developing in short iterations that provide the feedback that causes the system to emerge. Concurrent development makes it possible to delay commitment until the last responsible moment, that is, the moment at which failing to make a decision eliminates an important alternative. If commitments are delayed beyond the last responsible moment, then decisions are made by default, which is generally not a good approach to making decisions.
What does this mean in practice? It means not implementing that message queue until the day-to-day work on the project makes it obvious that one is needed, either by increasing code complexity or issues with scaling. It means not locking the company into that expensive observability platform until observability requirements are reasonably clear. And so on. It ties in quite neatly with the previous practice, the simplest thing that works.
What it does not mean is ignoring performace or scalability altogether, or refusing to do architecture, or hacking everything together. The last responsible moment is a tradeoff between the immediate needs of the current feature you’re working on and the cost of changing it in future. Code changes are generally cheap. Deployments are cheap. Changing the database schema and migrating all the data is expensive, so the last responsible moment is earlier.
Justify all the things (or, there’s no such thing as a free lunch)
Everything has a cost. That extra class causes an extra memory allocation. That extra frontend dependency makes the bundle that bit bigger. That extra abstraction increases the cognitive load of the developer. Etc.
Classes, design patterns, services, libraries and frameworks should be written/introduced only when they solve a concrete technical or business problem, and when the cost of not introducing them outweighs the cost of introducing them.
Consider this very reaal and definitely not made-up Slack chat beween two colleagues:
Foo: I added some interfaces to this class.
Bar: Why?
Foo (bad answer): because I read about interface segregation in a blog-post on SOLID.
Foo (good answer): because it’s an incoherent God-class which is leading to spaghetti-code across the application, and these interfaces are a step towards breaking it up into smaller coherent classes, and thus reducing the number of the code-paths in the application.
The next day, the conversation continues:
Foo: I introduced Reactive Extensions!
Bar: Why?
Foo (bad answer): Because they’re cool!
Foo (good answer): Because… [Sorry, there is no good reason to introduce RX.]
In general, think about whether you really need a new class for this functionality, or whether an existing one can be extended or re-used. Ask yourself whether that new framework really adds value, or whether it will just be another slow, complex dependency being dragged around by the codebase. Is a new service solving a scalability problem we have now, or is it just adding roundtrip times for no reason? I try to follow this rule-of-thumb: imagine that the developer who will have to maintain your code is a psychopath with severe anger issues, who knows where you live.
Clean up your mess!
The open/closed principle of SOLID tells developers not to touch existing, working code. When building device-drivers that require a high degree of backwards-compatibility, this advice makes some sense. When building a website, it’s the precise opposite of what you should be doing. Business requirements are constantly changing, meaning features are continually being updated and even removed altogether. What’s more, as websites scale, core workflows have to be rewritten to work in the new usage context. This often leads to the following:
- Large swathes of commented-out code from obsolete features that’s been left there ‘in case they’re needed in future’
- Whole code-paths that are never hit any more after a feature was updated, that noboy has bothered to remove
- The abuse of feature flags to leave dead code in the codebase
- Over-generalised abstractions with only one useage (like the aforementioned abstracted data-layer with only one database plugged into it)
All of these bad practices make code-maintenance a real headache. Imagine working on a codebase where three quarters of the code doesn’t do anything. If enough cruft accretes, then it becomes effectively impossible to change anything. Instead, refactor, refactor, refactor! Make refactoring and cleanup a part of your development workflow. Do it with every feature. Your future self (and that psychopath who knows your address) will thank you.
And what if we need some of this dead code again some day? Well, that’s what source-control is for…
Conclusion (or, there’s no silver bullet)
Like the Pirate Code, the architectural practices I’m advocating are more what you call guidelines than actual rules. I don’t claim them to be universal. As a web developer, I have found they have helped me to write scalable, maintainable code. They don’t always give me answers, but they do help me to ask the right questions.
In my next post, we’ll dive into what ‘simple’ looks like at the code-level.