A Habit That Stops Engineers From Writing Maintainable Code | by Edward Huang | Nov, 2022

It’s pointless to think about reusability

photo by johnson wang Feather unsplash

Every software engineer is used to speculation, and I am no exception.

This habit occurs when there is an impulse to make the code future-proof.

Instead of designing a solid, functional, easy-to-understand code for now, you start imagining all the things you could have done with the code.

One of the fundamental reasons that engineers thought about code reusability is that product requirements keep changing. Thus, all possible designs lead you to invent scenarios where your particular code would be useful under the new hypothetical future condition.

They become obsessed with every feature they design and every piece of code they’re about to write:

“How can I make it as reusable as possible?”

“What if in six months we also need X capability? I can add another layer of abstraction that will make it 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 come out with infinitely extensible and scalable code that is neither easy nor scalable because no one can understand it.

They have to prove that they can predict 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 a future that may never happen – arguments that are mainly speculation.

Fast forward to the future, the company hires a new developer who needed to develop features on top of the overly complex framework they did.

Now, they have to spend three hours during a whiteboard session explaining how this complex abstraction works.

Why did he do this to himself?

You spend a lot of time trying to make code beautiful, layers upon layers of elegant abstraction that is simple and worthy of a PhD. Thesis requires you to explain layers of code that are hard to debug.

The only thing all engineers can hope for in such abstract layers of code is that there will be no bugs in that system. I don’t want to fix a heavy abstract bug because I wonder if what I’m fixing will break other parts of the system.

You shouldn’t go too far on the “write code for now” side of the spectrum, or you have a hacky mess that needs to be refactored every time the codebase is touched.

There is a need to strike a balance.

I used to use design patterns to write the code structure. Staff engineers will point at my PR, and I need to justify my actions. He commented, “Why are you creating so many pointers that it becomes difficult for you to change the codebase later?” Then told me about a good rule for abstraction from this hacker news thread.

1. Rule of Three

The principle behind the rule is that it is easier to create a good abstraction from duplicate code than to refactor the wrong abstraction. When reusing code, copy it once and abstract only the third time.

“If something is used once, ignore any abstraction. If it’s used twice, copy it, that’s better. If it’s used three or more times, then an abstraction Consider writing what suits us today, not the future. Bonus points if the abstraction allows us to easily extend into the future, but doesn’t have to be anything appropriate with “what if.” — Hacker News

Let’s say you create a reusable function and need an explanation about what goes into the reusable piece versus what is already used. In that case, it’s usually a sign that you’re trying to make something reusable too quickly.

However, this statement is often misunderstood. Choosing the right way to generalize and cut code depends on the circumstances.

I often find 90% overlap between 2-3 use cases in our systems. For example, in payment system, authorize and refund are very similar in their flow, but payment provider flow has 2-3 different functions like model and some outliers. Many engineers make the main flow shareable across all operations and inject some flow parts with callbacks or configuration variables.

Initially, it works well when the main flow executions are the same. Nevertheless, product requirements and external integration flows change, making the entire system very tightly coupled and difficult to understand what happens in a given configuration.

Instead of creating an abstract system or interface to generalize parts of the flow because it’s different, try creating a function or component in which each use case has its own high-level code path that picks up on these functions. and chooses.

For example, an authorized payment flow requires service X for payment details. Then, it writes the session to database Y and commits the transaction to the payment provider Z. The refund flow needs to call service X. Then, it writes its value to database Y. Later, it calls service V to get refund information. Finally, it tells the payment provider Z to proceed with the refund.

Image showing authorized and refund flow
provided by the author

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 a configuration variable.

Instead of making it one huge interface, we can duplicate the authorization and refund flow into two separate flows. Abstract specific calls, such as service X, and write to database Y. Then, combine those methods into 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.

When your code is used in three different places, consider abstraction and extensibility. If you get pushback on a code review that asks you to exclude some abstract things, you should push back and ask them, ” What value will the abstract bring right now?,

If you see that the abstraction may need to be fixed halfway through building a reusable piece, then stop down that path. Document your ideas and share them with your team or as a lesson learned. Ignore all the thought of building that reusable code until you or some other engineer notices that the functionality has been used at least three times, copied in three different places.

2. Focus on making it easy to remove, rather than making it easy to replace

If you create a prototype, it’s okay to violate DRY and copy your code. If you’re developing an application for yourself or for internal teams, it’s okay to write long functions. Take all design patterns like DRY, YAGNI, and SOLID as suggestions, but not absolutes.

Every design pattern and principle should be viewed from the context in which they can be used. Do you need to create a strong test case if the system is only used temporarily and will be thrown away? Do you need to enforce DRY if you are creating a script to help yourself be more productive?

If an interrupt occurs based on the code you push, can someone identify your code and remove it without worrying about whether that change will cause further interruptions?

Someone who is new and needs to know the language should also be able to navigate and make extensible feature changes to the code.

The worst thing an engineer can do during his tenure is to maintain poor abstraction. They are either too specific because they were written without enough known use cases, or they are too general to cover too many possible use cases. Either way, code that is hard to change is usually not code that is duplicated, but code that is heavily reused. Any developer who needs to fix some bugs or develop a new feature on code that is used everywhere will always feel the concern.

“What if I change this code, and it breaks some features I haven’t accounted for yet?”

One philosophy I have in mind when designing a system, interface or code is to write only the code that is needed. When designing your system only consider extensibility as many never use.

If a field is redundant in the outer model, don’t put that field in the model you created. Let the future let you or other engineers deal with the new area when needed.

3. Be OK With Refactoring Your Code Over And Over

Codebases are not history books.

It is designed to be maintained and changed as per the needs of the customers and the product.

This is why codebases keep on evolving.

New and shiny design patterns will become obsolete, and a new way of writing code will emerge.

it used to be ” CorrectHow to write Scala Cake Pattern for Dependency Injection. However, engineers soon realized that cake patterns were less flexible than they used to be. In larger projects, engineers fused together all the functions using felt cake patterns. Once you’ve made ‘self-type’ as a dependency, those dependencies are hard to remove. He realized that you can’t just inject these components when needed.

You have to combine your infrastructure, domain services, persistence and business logic into one huge dependency. When you encounter a conflict during the rebase, you may end up in a situation where a new dependency pops up that you don’t have. The Scala compiler will yell at you, “Self-type X does not correspond to Y.” With 50+ components, you can only guess which one 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 frequently to improve the quality of their code.

If we don’t refactor them then our code starts to smell. For example, if we don’t divide up tasks, they tend to get bigger and bigger over time. Same goes with parameters – if we don’t refactor, our methods will be doing more and more things which can make it hard to read.

Code and systems are often designed to be maintained. It is like an organism that will continue to evolve.

The main point of building maintainable software is to build them to accommodate only what is currently needed.

Considering reusability and abstraction is only useful if your code can be changed or removed.

I always keep three tips in mind when I try to implement or change features:

  1. Assess whether something needs to be summarized with the rule of three
  2. Write your code in such a way that it is easy to delete in future
  3. Be OK with Refactoring When You’re Developing a New Feature

What other methods and suggestions can you do to make your system maintainable?

Leave a Reply