A Habit that Stops Engineers from Writing Maintainable Code And How to Avoid It
Thinking about Reusability is Pointless.
Every software engineer has a habit of speculation, and I am no exception.
This habit happens when there is an impulse to make code as future-proof as possible.
You start imagining all the things you could do with the code instead of designing a solid, functional, easy-to-understand code for now.
Thinking about Reusability is Pointless.
One root cause that made engineers think about code reusability is that the product requirements keep changing. Thus, all possible designs cause you to invent scenarios where your special code will be useful under the new imaginary future condition.
They become obsessed over every feature they design and every piece of code they are about to write,
"How can I make this as reusable as possible?"
"What if in 6 months we also need X capability? I can add another layer of abstraction that will make this extensible."
"We need to put in a Queue to make it asynchronous because what if we have a high load ingestion and the server is overwhelmed?"
"We want to abstract this function into a strategy design pattern because what if marketers want to implement more algorithms?"
Then they leave with infinitely extensible and scalable code that is neither easy nor scalable because no one can understand it.
They must prove that they can prophesize the future if they get any pushback against their layer cake of abstraction and design patterns.
They waste engineering time and resources arguing with other engineers about the future that may never happen - arguments that are mainly speculation.
Fast forward to the future, the company hires a new developer who needs to develop features on top of the overly complex framework that they did.
Now, they have to spend three hours during a whiteboard session explaining, in deep frustration, how that complex abstraction works.
Why did they do this to themselves?
You spend a lot of time trying to make the code beautiful, layers upon layers of elegant abstraction that is ingenious and worthy of a Ph.D. thesis. You need to explain such layers of code that are hard to debug.
The only thing that all engineers can hope for in such abstracted layers of code is that there won't be a bug in that system. I don't want to fix a heavily abstract bug because I wonder if what I am fixing will break other parts of the system.
You shouldn't go too extreme on the "write code for right now" side of the spectrum, or you're left with a hacky mess that needs to refactor every time the codebase is touched.
There needs to be a balancing act.
Three Tips to Stop the Speculation Habit
I used to love using design patterns to write code structures. Staff engineers would point that out on my PR, and I needed to justify my actions. They commented, "Why are you creating so many indirections that make it harder on yourself to change the codebase later?" Then linked me to this Hacker News thread about a good rule of thumb for abstraction.
1. Rule of Three
The principle behind the rule is that it's easier to make a good abstraction from duplicated code than to refactor the wrong abstraction. When reusing code, copy it once and only abstract the third time.
"If something is used once, ignore any abstractions. If it's used twice, copy it, it's better. If it's used three or more times, look at writing an abstraction that suits us TODAY, not for the future. Bonus points if the abstraction allows us to extend easily in the future, but nothing should be justified with a "what if." - Hacker News
Suppose you create a reusable function and need clarification about what goes into the reusable piece versus the one already being used. In that case, it is usually a sign that you are trying to make something reusable too early.
However, this statement is often misconstrued. Choosing the right way to generalize and shred code depends on the circumstances.
I often found a 90% overlap between 2-3 use cases in our system. For instance, in the payment system, authorized and refund are very similar in their flow but have 2-3 different functionalities, such as models and some outliers in the payment provider flow. Many engineers abstract the main flow to be sharable across all operations and inject certain flow parts with callback or config variables. Initially, this works well when the main flow executions are the same. Nevertheless, product requirements and external integration flow change, making the entire system very tightly coupled and harder to understand what happens in a given configuration.
Instead of creating an abstract system or interface to generalize certain parts of the flow because it is different, try to create a function or component in that each use case has its own high-level code path that picks and chooses from these functions.
For instance, authorized payment flow requires service X for payment details. Then, it writes the session in database Y and makes a transaction on payment provider Z. Refund flow needs to call service X. Then, it writes its value to database Y. Afterwards, it calls service V to get the refund information. Lastly, it calls payment provider Z to proceed with the refund.
Most engineers will abstract the payment flow into a single interface by calling service X, database Y, and payment provider Z. Then, any other specific calls will be put into a callback function or config variables.
Instead of making it a giant interface, we can duplicate the authorization and refund flow into two separate flows. Abstract the specific calls, such as service X, and write to database Y. Then, combine those methods in two separate flows.
This creates ease of readability because high-level steps are easier to reason in the human brain than low-level abstract syntax.
Consider abstraction and extensibility when your code is used in three different places. If you get pushback on a code review asking you to certain abstract things out, you should push back and ask them, "What value will the abstraction bring RIGHT NOW?".
If you notice the abstraction might need to be corrected halfway through building a reusable piece, stop continuing down that path. Document your thoughts and share them with your team or as lessons learned. Ignore all the ideas of building that reusable code until you or some other engineers notice that the functionality has been used at least three times, copied in three different places.
2. Focus on making it easy to delete instead of making it easy to change
If you create a prototype, violating DRY and duplicating your code is okay. It is okay to write long functions if you are developing an application for yourself or internal teams. Take all the design patterns such as DRY, YAGNI, and SOLID as suggestions, but not absolutes.
Every design pattern and principle should be viewed by the context in which they can be used. Do you need to create a robust test case if the system is only used temporarily and will be thrown away? Do you need to implement DRY if you are creating a script to help yourself be more productive?
If there is an outage based on the code you push, can someone identify your code and delete it without worrying if that change will cause a further outage?
Someone who is new and needs to learn about the language should also be able to navigate and make extensible feature changes to the code.
The worst thing engineers can do during their tenure is maintain bad abstractions. They are either too specific because they were written without enough known use cases, or they are too generic that cover too many possible use cases. Either way, code that is hard to change is usually not the code that is duplicated but code that is overly reused. Any developer that needs to fix some bugs or develop a new feature on the code that is used everywhere will always feel anxiety.
"What if I change this code, and it breaks certain features somewhere that I have not yet accounted for?"
One philosophy I have in mind when designing a system, interface, or code is to only write the necessary code. Consider extensibility only when designing your system because many never get used.
If a field in the external model is unnecessary, don't put that field in the model you create. Let the future you or other engineers deal with the new field if needed.
3. Be Okay with Refactoring Your Code. Often.
Codebases are not history books.
It is created to be maintained and changed according to customers' and product needs.
That is why codebases keep evolving.
The new and shiny design pattern will be obsolete, and a new way of writing code will emerge.
It used to be the "correct" way to write the Scala Cake pattern for dependency injection. However, engineers soon realized that Cake patterns are less flexible than they seem. In bigger projects, engineers realized that using the Cake pattern tightly coupled all functions into one. Once you create a `self-type` as a dependency, it is hard to remove those dependencies. They realized that you could not inject these components only when needed. You must combine your infrastructure, domain services, persistence, and business logic into one gigantic dependency. When you encounter a conflict during rebase, you might end up in a situation where a new dependency pops up that you do not have. The Scala compiler will yell at you, "self-type X does not conform to Y." With 50+ components, you can only guess which fails or start doing a binary search by removing components until the error disappears.
As product requirements evolve and your customer base changes, engineers should be encouraged to refactor their code often to improve their code quality.
Our code starts to smell if we don't refactor them. For instance, if we don't split functions, they get bigger and bigger over time. The same goes with parameters - if we don't refactor, our methods will be doing more and more things that may cause it harder to read.
Code and systems are designed to be maintained often. It is like an organism that will keep evolving.
Conclusion
The key point of building maintainable software is only building them to accommodate what is currently needed.
Overthinking reusability and abstraction is only useful if your code can be changed or deleted.
The three tips I always keep in mind when I try to implement or change features are:
Assess if something needs to be abstracted with the rule of three
Write your code in a way that is easy to delete in the future
Be okay with refactoring when you are developing a new feature
What other ways and tips can you do to make your system maintainable?