There is no silver bullet to good code
Summary
Until recently, I thought I had a pretty decent understanding of how one should write good code. The one and only true and right way, of course.
It’s decently abstracted object-oriented (OO) SOLID Clean Code, by the way. Mine (and my peers) interpretation of it, of course. Or something close to it, on a good day, if I am being perfectly honest.
I was raised in Kotlin/Java enterprise development, lived in a bubble, where the standards and best practices that emerged in that realm were put on a pedestal and seemed so universal that I thought I could and should apply them everywhere, always.
What can I say, my religion-like views were quite shaken.
I left the Kotlin/Java realm and moved to Golang, my habits thrown into a little suitcase I brought along with me. Some of you might already know what’s to come.
It all started with a PR comment asking me to refactor my tiny little helper function back into its parent. A comment left by a very skilled person, so I knew my usual dogmatism on that needed to be replaced with a search for understanding where that came from. To me, these things were not mere taste, but facts that differentiated good and bad programmers. I was, indeed, very wrong.
Curious to learn more, I quickly found myself in yet another rabbit hole.
The further my research deepened, the more of the concepts I strived to apply had tomatoes thrown at them. Material about OO/SOLID/Clean Code being bad popped up everywhere and I was unable to ignore them once they caught my attention. What’s wrong with all these people roasting THE best practices? So I thought initially. I ended up figuring out that actually, something was wrong with me.
There is no silver bullet to good code.
This is mostly a collection of resources I found insightful, for anyone who might be interested to explore that rabbit hole and find some entry points.
Heavily featuring ThePrimeTime content. Great guy. Also mostly videos, as I personally prefer multi-media content to walls of text (ironically).
I am a mid-level developer currently reflecting on my journey so far, talking to myself here, this might be an interesting read for folks in the same position.
While I reference philosophies and approaches common in Java and Go, I think this can be applied language-independently.
Burn your bible and stay open to criticism
Whenever you meet someone who doesn’t view your mini-functions and smart abstractions as ideal or even good at all, don’t just label them as bad engineers and move on. There are — in fact — very valid reasons and good arguments behind that stance and you’ll find out there’s even more to it once you dig deeper. I’ve learned that I should build my opinions on experience or at least proper research, learn that things are nuanced and complex and keep an open mind no matter how big of a stan I am of whatever the matter. Blind dogmatism can lead to stagnation in learning. Especially as a mid-level developer, you have seen and learned a lot thus far and that might lead to a false sense of confidence, in the worst case leading to you being stuck at being mid without even realizing. Try not to live in an echo chamber.
Here’s a great article further illustrating what I’m trying to say: https://medium.com/@thecodingteacher_52591/what-actually-matters-for-code-quality-b2ec0a3aa301
Without further ado, let’s dive in.
Code structure, “developer experience” and the costs of abstractions
_______________________________________________________________
A big disclaimer: When I mention Clean Code in this article, I only mean “make thing small and polymorphic”, which is only one of many takeaways from the book, but in the center of the recent wave of criticism. More on that differentiation later.
_________________________________________________________________
Valid criticism of Clean Code and OOP in general and tendencies for an abstraction fetish has been out there for years, I just never thought about even doing research on that because I never questioned it.
First, I got used to OO and then, Clean Code helped me to write generally better OO code. Also, it was the desired standard at all of my workplaces thus far. The software we built worked well, most of our abstractions and hierarchies did us a great service, especially during work for a B2B SaaS-company that regularly had to customize certain aspects of the system for individual customers. When coding and debugging was a headache and jumping around various numbers of subroutines, files and layers of abstractions, I just thought “well, sometimes things are just complicated” and dealt with it.
Any dev who worked on an at least moderately complex object-oriented application (that claimed to be) built around SOLID, Clean Code etc. principles knows that there is no such thing as all sunshine and rainbows. Buzzwords like extendable, maintainable, etc. are thrown around pretty quickly, especially when justifying adding additional layers of abstraction (prematurely).
Software is not only modeled according to what is theoretically optimal (?), but also according to logical and functional concepts, which sometimes simply shouldn’t be artificially separated until the separation feels natural and organic. Separation at a detail level also often has less to do with actual technical aspects and more with developer preference. Uncle Bob also says he just likes it better that way. So is it just preference? Therefore subjective? Yes, but also no. We’ll learn about that later.
I do get the point when people argue that we OOP/SOLID/Clean Code fans have a thing for just hiding complexity behind walls of cleverness so that we can be proud that it looks simple in that one place. Until it blows up because the abstraction doesn’t work anymore and a choice between a big refactor or working around the problems with your current abstraction (just hack it in — worse choice here) is due. The latter is where the commonly feared design flaws start creeping into your project. Sadly, realistically, that happens sooner or later. Unless you’re lucky enough to work in a team of extremely well organized, disciplined people.
Maybe it depends on what kind of pain you and your teammates feel more comfortable with. Complex object hierarchies with small, neat and all tidied up single-responsibility functions spread over multiple files or long, procedural carpet-functions that won’t fit into a horizontally aligned monitor anymore, but you can read like a book without any or with minimal jumping around. Hardcore DRY and therefore risk entanglement of certain code snippets throughout the whole codebase or hardcore KISS, take the cost of repeating yourself for the sake of being able to narrow changes and impact down to only the very place you work on. Both principles offer different risks for bugs being introduced. Both can work well together in some settings and oppose each other in others.
In the end, we’re all just trying to handle complexity in a way that makes it manageable. I think to some degree “well, sometimes things are just complicated” won’t go anywhere. And that’s ok. Is this a free pass to just write unstructured spaghetti code and call it a day?
Definitely not.
There are other methods for useful code structuring like sections with section comments instead of helper functions, using modules/namespaces with clear boundaries and interfaces to the rest of the code, sprinkle in some OO patterns where they benefit the structure, and other rules that help writing good (mostly) procedural code.
Take this video:
I used to be an extremist Never Nester, heavily using extraction. Now, I exhaust every inversion possibility first and if the code is still hard to understand because of too much nest, I extract. If we have a function with multiple code blocks and no extensive nesting, describing the sub-steps of a process with section comments (instead of the function name) can be used as an alternative to extraction into a subroutine. Optionally wrap the block with {} for Editor code folding, and you’ll also have that high level, descriptive overview of a process.
Which — in both ways of achieving it — definitely makes the thing more understandable. There isn’t just one way of doing it.
I’ll now try to be a balanced Never Nester, always checking if extraction actually benefits readability. A lot of times, one can enhance readability with tricks that don’t require extraction (and therefore less stack/heap entries involved) at all.
Resources:
General guidelines and good (Java/mostly OO) code:
On mindful abstractions, great comment section:
Good procedural code:
On inlined code: http://number-none.com/blow/blog/programming/2014/09/26/carmack-on-inlined-code.html
A Clean code rant:
Prime (a Clean Code critic) interviewing Uncle Bob himself (Clean Code interview starts at 10:00):
Some thoughts on SOLID
Why Clean Code can be hard to maintain:
Can we finally stop with premature optimization now?
One of the mayor things criticized about the industry standards in enterprise backend development is that the philosophies and methodologies foster habits of premature optimization and over-engineering.
A habit of striving for abstractions from the very start seems misguided. In Primes Interview with Uncle Bob, he says that he starts with the most concrete implementation and starts abstracting once the problem forces him to (see the interview listed in the section above, starting at ~16:31).
And that is a primary example of the trade-offs of only reading a book. When you’re unable to ask clarifying questions to the author, you’ll always end up with your interpretation of the material, which can stray from what the author was actually trying to advocate for. Many people justify their premature abstraction fetish with Uncle Bobs teachings. Those should really, really watch this interview.
Coming up with the right abstractions is harder than we like to admit. It takes time and a broad understanding of the requirements and how the abstractions can benefit them. We are not clairvoyants and it is very difficult to develop a really sensible strategy on the basis of “something could happen at some point”. I always struggled with this, and I am very happy to see a trend of people admitting that they also aren’t “smart” enough to “properly” prematurely optimize. In the worst case, the wrong abstractions can lock you into something instead of providing the desired flexibility. In less bad cases, you and your team simply wasted your time.
An example: There was once a project where we tried to do everything quite strictly like “we’re supposed to do”. Without exceptions. For example, everything came with an interface, an abstract and a concrete class. In case we’d have to add a different implementation at some point in some possible future. Well, 10, 20 years passed and some interfaces are still waiting for that second implementation warranting the whole hierarchy. It will probably never come. We ended up with a lot of dead, overly complex code that didn’t benefit us. So some of us wondered — Should we stop building interfaces in stock when the use case gave us no reason to, therefore violating “Program to an interface, not an implementation.”?
And so it began — We started to write bad code.
The thing is, if you woke up any of us at night and asked us to design an interface/abstract/concrete hierarchy like proper engineers do, we’d all be perfectly capable of doing so because that’s what we have done over, and over, and over again. Having worked with the hierarchy and understanding how it benefits us in situations where we actually need inheritance helps crafting a design that is — indeed — pretty easily changeable despite being concrete at the moment. Define sensible APIs to the rest of your code, just like you’d do if those method definitions lived in the interface from the start. Hide implementation details behind that façade. No need for an interface to do that. You just skip the boilerplate until it actually enables different behavior because there are multiple implementations. And bam, you introduce complexity at the moment you actually need it and it’s still possible. Yeah, you’d have to touch a few more places, like switching from the concrete class to the interface where the thing is used or defining which impl should be used where. So be it. Not a deal breaker. With the help of modern IDEs, those changes are performed quickly. I really like this approach because I’m lazy and hate doing things that I don’t see the point of.
And now look at this:
This perfectly aligns with our findings and experiences. Found here https://sourcegraph.com/blog/go/idiomatic-go in a blog post about idiomatic Go. I learned that lesson from having to work with over-engineered code.
Despite that, I’m definitely guilty of doing stuff like this:
I currently work on sharpening my radar regarding this. Wise people say that doing things out of principle or because of vague, hypothetical scenarios has often caused more problems than it has solved. So I have been told, and it aligns with my own limited, but lived experience.
Apart from that, I think being afraid of having to introduce more or less radical changes in the future is a smell. One that stinks like “never change a running system” and mistrust in the capabilities of your team. And with team, I mean everyone involved, from Product Manager, over QA to devs. You’re scared something will break? Make room for that and test, test, test. Anyone who worked on a rather old legacy system where that fear of change dominated the culture probably found an outdated, workaroundy shithole of bad code instead of high quality software. Even if it was originally built by highly skilled people who put a lot of thought into the initial design back in the days. People sleep on this for decades and are then forced to rewrite the whole thing from scratch because the codebase became an obfuscated mess and has strayed so far from modern standards that nobody wants (or is even able) to work on it anymore. It doesn’t have to be that way. Even the most state-of-the-art, shiny new systems will turn into horrific legacy codebases if they aren’t properly groomed and adjusted. Sorry, you’ll have to touch your code anyways. Crafting a perfect design that will meet all your future needs is nearly impossible.
With the dynamic nature of our industry and the evolution of technology, we must adjust, sometimes radically. Good software is a continuous effort. There’s no such thing as a finished product. Everyone involved in the process of creating software must understand that. At least if they care about software and don’t just see it as a money printing machine.
Okay, rant over.
“Build your software with the best understanding of the problems you are facing right now” is great advice I think. Sounds like a reasonable middle-ground between over-and under-engineering. And yes, that is a spectrum.
When people say “avoid speculative design”, they don’t mean “don’t design at all”. Of course, we can and should use patterns and principles available to solve the problem we have at hand in an elegant way. It’s all about knowing the rules and the right time and place for applying them. Even if you don’t agree with the majority of the backlash on “proper enterprise code”, that is a takeaway we should all be able to agree on.
Resources:
David Farley on the thin line between over-engineering and writing quality code:
The YAGNI principle:
https://www.geeksforgeeks.org/what-is-yagni-principle-you-arent-gonna-need-it/
On speculative abstraction and hiding complexity:
On (infra) complexity:
I can’t recommend this enough:
Performance-aware programming
I also completely slept on the Casey Muratori vs Uncle Bob thing.
While I don’t agree with using Muratoris analysis as a manslaughter argument proving that Clean Code as a whole methodology is evil and wrong and should never be used, it gave interesting insight on the impact that coding style can have on your projects. Robert C. Martin stated that Clean Code was never about optimizing code performance, but about optimizing developer performance. It’s hard to argue on the fact that committing to some sort of standard for projects multiple people work on has an impact on the team’s performance.
Does that standard have to be Clean Code? Not necessarily.
Are you working with an ecosystem that heavily influenced and works well with that standard (e.g. Java enterprise development)? Then, I think Clean Code is a good idea since it’s commonly known, agreed upon, and makes a lot of sense in the context of OOP and it’s challenges. With heavy OOP, you have to jump around your code anyway. Also, if you wanted to build for high performance, Java probably isn’t the language of your choice anyway.
However, if you work with a high-performance language like Go, you do technically work against that advantage when choosing a coding convention that has a built-in performance drawback.
Uncle Bob says that Clean Code is very applicable to any C-style language, and while that is true, the question remains whether that style should (always) be used there. Another takeaway from that debate was that I finally understood that long, procedural-style code blocks I sometimes stumbled upon in e.g. Springs source code were not bad code and also suspiciously often appeared in places where performance was relevant.
When I now see a really long function, I don’t instantly think “this is bad” and scan it for parts that can be pulled out (and then do that and do git commit –m “refactoring ugly code”). I take a look at the context and consider that too.
An argument often used by people defending Clean Code is that we nowadays have access to machines so powerful that you no longer need to squeeze out “every millisecond” for better performance. And that not everyone is a game developer and/or working on performance-critical codebases. Initially, I was fully on that team, but Muratori got me thinking with the following. He argues that this mind state leads to people erasing years and years of hardware improvement by building this way. And that software itself is often terribly slow nowadays. Sure, if you host your product on a powerful cluster of high-end servers, your code will run fast. Problem solved? Basically every company constantly complaining about hardware bills (especially in the Cloud) would disagree.
Just as an example, AWS EC2 instances get pricy rather quickly. It does make a difference If we’re able to use a smaller one and still provide the same amount of performance to the end user. We can complain about triple A game devs requiring us to buy a new PC every few years, but are we really any different if we play the “hardware is better nowadays” card too?
With cloud computing, it’s easy to lose sight of how our products affect resources in the real world. You no longer physically see your server racks grow or hardware being switched to match growing requirements. You klick a bunch of buttons or issue some commands and just like that, performance issues be gone. All of this has a rat’s tail. The resources that back our infrastructure don’t grow on trees and we should strive towards reducing wasteful consumption of them.
Is this idealistic thinking? Maybe. But I think we should stop with that quite ignorant “I don’t care”-attitude and try to do the best we can here.
Resources:
Casey Muratoris performance analysis that went viral:
A reaction that aligns with my initial opinion, great points in the comments:
Uncle Bob and Casey Muratori debating on GitHub: https://github.com/cmuratori/misc/tree/main
Do we need a new Clean Code?
Clean Code became so popular because it tried to form a central base of knowledge. Ideas crafted together to form a whole methodology. It’s comfortable to have a “single source of truth” and cognitive dissonance demands more of our brain than simple true/false binaries. My mind urges me to go find a different methodology, a new book to read, new best practices to follow. There’s probably already one out there, still unrecognized by the masses. I honestly don’t care about that new JS framework. Give me the new right way to code!
Our standards shouldn’t be replaced with no standard at all, we should instead look out for one that centers performance and maintainability as an integral part of every application. But maybe, I shouldn’t be aiming to find a new bible. Finding and taking bits and pieces of all those ideas brilliant people out there have and factoring them in to compliment the area I am currently working on will probably provide me with a less biased and more open mind towards the craft.
On the implementation details level, I think locality of behavior (https://htmx.org/essays/locality-of-behaviour/) can be a great alternative to mini-functions. That would be just swapping certain Clean Code recommendations out with others. Which definitely doesn’t automatically lead to Spaghetti code (what were all the other rules for if that was the case?). Uncle Bob is actually currently writing a new version of Clean Code. I am very curious to see what changes to the old version he introduces and if they address the criticism. Also, one major thing gets lost in this debate: not everything Clean Code advocates for is considered problematic.
Here’s a nice summary of Clean Code: https://gist.github.com/wojteklu/73c6914cc446146b8b533c0988cf8d29
My impression is that most of the critics actually don’t oppose any of these ideas:
General:
- Follow standard conventions.
- Keep it simple stupid. Simpler is always better. Reduce complexity as much as possible.
- Boy scout rule. Leave the campground cleaner than you found it.
- Always find root cause. Always look for the root cause of a problem.
Design rules:
- Keep configurable data at high levels.
- Separate multi-threading code.
- Prevent over-configurability.
- Keep configurable data at high levels.
- Use dependency injection.
- Follow Law of Demeter. A class should know only its direct dependencies.
Understandability tips:
- Be consistent. If you do something a certain way, do all similar things in the same way.
- Use explanatory variables.
- Encapsulate boundary conditions. Boundary conditions are hard to keep track of. Put the processing for them in one place.
- Avoid logical dependency. Don’t write methods which works correctly depending on something else in the same class.
- Avoid negative conditionals.
Naming:
- Basically everything in the naming section
Functions:
- Use descriptive names.
- Prefer fewer arguments.
- Have no side effects. (More realistically, avoid them.)
- Don’t use flag arguments. Split method into several independent methods that can be called from the client without the flag.
Comments:
- Don’t comment out code. Just remove.
- Use as explanation of intent.
- Use as clarification of code.
- Use as warning of consequences.
Source code structure:
- Separate concepts vertically.
- Related code should appear vertically dense.
- Declare variables close to their usage.
- Similar functions should be close.
- Don’t break indentation.
- Use white space to associate related things and disassociate weakly related.
Objects and data structure:
- (Hide internal structure.) Muratori, Internet of Bugs and Carmack mention that this can be problematic. I’m unsure about this, probably more on the “it depends” side.
- Prefer data structures.
- Avoid hybrids structures (half object and half data).
- Base class should know nothing about their derivatives.
- Better to have many functions than to pass some code into a function to select a behavior.
- Prefer non-static methods to static methods. (static = global)
Tests:
- Readable.
- Fast.
- Independent.
- Repeatable.
Code smells:
- Rigidity. The software is difficult to change. A small change causes a cascade of subsequent changes.
- Fragility. The software breaks in many places due to a single change.
- Immobility. You cannot reuse parts of the code in other projects because of involved risks and high effort.
- Needless Complexity.
- (Needless Repetition.) *Define needless. Repetition > illogical/bad abstraction.
- Opacity. The code is hard to understand.
Which is basically almost everything with just the exception of some bits that get mistaken to be the whole point of the book.
Clean Code is much more than the oversimplification of “make thing small and polymorphic”.
I needed a reminder of this and also discovered that I could use a refresher on some of these points. So I learned something on that too. Further proof of the fruitfulness of civilized discourse.
Conclusion
Good code can come in different shapes and sizes. Brushing up on that vague definition from time to time is a good thing to do.
- Learn the rules first, then when to break them.
- Product context is important.
- You can’t simply copy-paste all of your paradigms and best practices to every other place.
- Everything has benefits and pitfalls, do not follow blindly
- Over-engineering is not smart — making stuff complicated is simple, making stuff simple is hard.
- Base important decisions, like complex abstractions on actual requirements and evidence, not guesswork.
Last but definitely not least: great insight can come from pricking up your ears and listening. I wouldn’t be half the dev I am today without the input of great minds I’ve met on my journey. And I’m definitely not done learning.
Stay open minded, care for your craft.
