Reusable code is a holy grail of programming. Software engineers are obsessed with creating utility libraries and functions that can reuse by all brand ranges of business applications.
They argue all day over coffee about making their code more reusable and not reinventing it repeatedly.
As they continue to evolve their application into the future, the idea of reusability soon becomes oblivion. Due to feature creep and disproportional API growth rate, the software becomes bloated and harder to read and maintain.
I got a comment on my PR from one of the senior engineers for not abstracting my code enough to make it reusable this past week. I created a function with good enough similarities to other functions but harder to merge them at once without changing the structure of the existing function.
We often encounter refactoring dilemmas while building new features in a large codebase.
"To change or not to change."
Trying to refactor the existing feature and make it reusable sounds like a great idea. However, premature optimization is a real thing that will haunt software teams in the future.
We have a mini discussion about whether or not we should fit the new feature into the existing framework to have the existing function be more generic and not have one business focus.
I object to his statement.
When we try to make a framework or function to be generic, it increases complexity and decreases reusability. It doesn't serve the original purpose that we wanted to achieve: maintainability.
Through this story, I realized simplicity is paramount for software maintenance.
In this article, I want to share why it is hard to achieve simplicity in software and give you three tips for designing a simple system.
Why is simplicity hard to achieve?
Research shows that human nature favors adding over subtracting in problem-solving. One example is that Adams and colleagues analyzed archival data and observed that when an incoming university resident requests suggestions for changes, the university will better serve its students and community. They realized that only 11% of the responses involved removing an existing regulation, practice, or program. When a software engineer needs to add features to the existing codebase, instead of refactoring what is in it and reusing some parts, they often create a new flow or functions on top of the codebase. Although it is unclear why we use addition instead of subtraction in problem-solving, it causes our system and codebase much more complex.
Simplification is time-consuming. We must take more time to understand what is important to achieve simplicity. We must work backward to understand different use-case and scenarios to understand what is important. Thus, it requires research, planning, and effort. John Maeda, an American designer and technologist, provides ten rules for simplicity:
Reduce. The simplest way to achieve simplicity is through thoughtful reduction. When in doubt, remove.
Organize. It makes a system of many appear fewer.
Time. Saving time feels like simplicity while waiting feels like complexity.
Learn. Knowledge makes everything simpler.
Differences. Simplicity and complexity are like a yin and yang - when one prevails, the other stand out.
Context. Always give context to make the source more simple.
Emotion.
Trust. In simplicity, we trust, despite the consequences it brings.
Failure. You have to learn from your mistake to simplify the next thing.
The one. Simplicity is about subtracting the obvious and adding the meaning.
People often confused simplicity with minimalism - they thought they were synonyms. Minimalism seeks to convey the essence of the objects - it is about the form: a lot of space. On the other hand, simplicity means the absence of unnecessary elements. Simplicity emphasizes what is important. Simplicity causes minimalism, but not the other way around. Minimalism is easy to understand because we can visually see it. However, simplicity is an abstract term that often debates among software engineers.
3 Tips to Design a Simple System
Reduce The amount of Abstraction in Your Code
One of the works I recently submitted has a method that decodes external sources into an internal model. The codebase already contains a similar decode method. However, the decode method is very specific a geared toward other business flows. As a good engineer, I refactor one of the existing decode methods to avoid repeating myself. I looked at the decode method, and it has three steps:
base64 decode
Decode JSON string into JSON object.
Return one of the JSON attributes in the JSON object.
Of the three steps, the more specific one is the last step, returning a specific JSON attribute to the caller. Thus, I created a helper method that encapsulates the first two steps and has the existing decode method use the helper function. My PR got commented by one of the senior engineers in the team that I should create a class on the decode function because object creation should be put in the class.
"Why do we want to create another indirection just to decode a single message?" I thought.
Adding more abstraction when out is not needed is a sign of over-engineering. I could make decode
as an interface and refactor the codebase structure to implement the strategy pattern.
Asks yourself when you try to add more abstraction, "What is the value that this will bring to our process? What complexity will this bring to the system?"
The answer always depends.
Creating a class and abstraction in this scenario doesn't add as much value because the application only decodes two messages. An if/else statement in the decode function may add cyclomatic complexity to the original decode function. However, that increases the time to refactor the entire codebase structure and makes it harder to trace your code afterward.
What is the downside of abstraction? More complexity in navigating through your code. Suppose you create an interface of decode
that can be extended through multiple pub-sub implementations. You felt like you have made the code extensible in the API layer. However, the cost of making such a generalization makes your code much more complex. A new engineer trying to extend a new feature will require a longer understanding of all the layers of abstraction you created.
Moreover, tracing and debugging code takes much longer. One reason is that when we track down lines of code through all the abstraction layers, we must also create a stack in our memory to remember all the steps we took. It becomes hard to understand a piece of business implementation with multiple abstractions. If you use any IDE or even vim, you must do Find All References
every time you try to trace through the implementation.
Avoid Writing Generic Code
Many mistakes that appear in the complex codebase are often too generic software.
We want to build a single, flexible solution applicable to various use cases and environments. Thus, generic is the basic requirement for reusable software. However, making your software generic will hurt usability - making your software unusable, which defeats the original goal of software reusability.
For instance, given a list of words, return a Map of all the frequencies of that words.
This problem can be written by:
creating a hash map
loop through all the words in the list
put the word as the key and the frequency as the value.
def countFreq(lst: List[String]): Map[String, Int] = {
lst.foldLeft(Map.empty){(acc,el) =>
val count = acc.getOrElse(el, 0) + 1
(el -> count) + acc
}
}
The generic way to do this is to use monoid to transform the list of words into a tuple of a word and its initial frequency of one and use foldMap to reduce all the list of entries into a single Map value. It is the same implementation as map-reduce.
def mapMergeMonoid[K,V](V: Monoid[V]): Monoid[Map[K, V]] =
new Monoid[Map[K, V]] {
def zero = Map[K,V]()
def op(a: Map[K, V], b: Map[K, V]) =
(a.keySet ++ b.keySet).foldLeft(zero) { (acc,k) =>
acc.updated(k, V.op(a.getOrElse(k, V.zero),
b.getOrElse(k, V.zero)))
}
}
def foldMapV[A, B](as: IndexedSeq[A], m: Monoid[B])(f: A => B): B =
if (as.length == 0)
m.zero
else if (as.length == 1)
f(as(0))
else {
val (l, r) = as.splitAt(as.length / 2)
m.op(foldMapV(l, m)(f), foldMapV(r, m)(f))
}
val intAddition: Monoid[Int] = new Monoid[Int] {
def op(x: Int, y: Int) = x + y
val zero = 0
}
def countFreq(lst: List[String]): Map[String, Int] = {
foldMapV(as, mapMergeMonoid[String, Int](intAddition))((a: A) => Map(a -> 1))
The second generic approach can account for all types, not just a list of words.
However, which codebase would you like to maintain? I'll let you decide on that yourself. It takes multiple steps for the human brain to understand what it does.
Although generalization has many benefits in not repeating yourself (DRY), the cost of generalization can impact your team's velocity in maintaining and extending features. Suddenly, changing a tracking event can take multiple days to achieve because we need to learn all the generalizations in our codebase. So pick your battles.
Reduce the Amount of Computing Resources When Designing Your System
The cloud computing and microservice marketing campaign has influenced software engineers to put multiple layers of useless service in their architectural design.
Daniel J Sturtevant mentioned in his paper on System design and the cost of architectural complexity that architectural complexity costs developer productivity, software quality, and turnover. He had a study that tested the hypothesis that architecturally complex files experience more defects. Moreover, he studies the architectural complexity of individual files and the amount of bug-fix development acidity that occurs in those files. He found that the more complex file has 2.1 times more bugs to fix than the other.
He also mentioned that developers working in architecturally complex systems are likelier to leave the firm. He looked at the relationship between the faction of lines of code an individual contributes to those services relative to peers who don't contribute to those systems in the span of 8 release windows and the subsequent four years.
If you can design your application in a monolith, you should do it.
A cloud function is between google cloud pub-sub and data flow job if a pubs queue can connect to the data flow job immediately.
More service equals more single points of failure. Suppose we put multiple service layers between services A and B. In that case, we must account for all the failure scenarios that intermediary service may cause.
And those are more engineering resources and computing resources.
Recap
Simplicity is underrated in software. Software engineers love adding abstraction and code to the problem rather than removing the existing architecture. Thus, one needs to research to understand what is important to achieve simplicity.
Making code simple is not about decreasing the number of lines of code. To write simple code, reduce the amount of abstraction that you have. Start with the most rudimentary implementation and increase its complexity by putting layers of abstraction if needed.
As a popular saying goes, duplication is cheaper than the wrong abstraction. Thus, think twice when writing a generic function because generic functions are often not reusable.
Lastly, start with no indirection when designing a system and increase the complexity as you see fit. For instance, if you want to design a checkout payment system, starting with having Frontend calls API Gateway (Stripe) right away will be the simplest design. Then, evaluate if there is a need for any microservice in the middle to make the development process more efficient.
Do you have any other tips to make software and processes more simple? Comment them down below!
š” Want more actionable advice about Software engineering?
Iām Edward. I started writing as a Software Engineer at Disney Streaming Service, trying to document my learnings as I step into a Senior role. I write about functional programming, Scala, distributed systems, and careers-development.
Subscribe to the FREE newsletter to get actionable advice every week and topics about Scala, Functional Programming, and Distributed Systems: https://pathtosenior.substack.com/
Top comments (0)