Topic of A Great Software Design
Advice on how to approach the software design process, identifying red flags, and managing complexity. How to turn a complex design system into a simple easy-to- understand system?
Introduction
Writing software is all about creativity. If you want to improve your creativity then writing software is one of the best creative activities. Programming doesn’t require great skills, however, programming requires a creative mind and the ability to organize.
The greatest limitation of programming is our ability to understand the system of what we’re creating. As technology keeps on advancing, programs will evolve and have more features which would become more complicated. Programs become more complex making it harder for programmers to modify the system. In turn, it will slow down development and leads to more bugs. This would drive up the cost for business.
The two ways to fight complexity are by making code simpler to understand and another approach would be to encapsulate it, so programmers can work on a system without being exposed to complexity.
This is called modular design and in modular design, systems are divided up into modules, such as classes in an object-oriented language. Programmers can work in one module without focusing on the other modules.
Most software development projects use an approach called agile development, in which the initial design focuses on a small subset of the overall functionality. The subset is designed first, then implemented, and last evaluated. We basically spread out the design, so that problems can be fixed while the system is small.
The best way to improve your design skills is to recognize red flags. Red flags such as a piece of code that is more complicated than it needs to be. When you see a red flag, look for an alternative design that will eliminate the problem.
Complexity
The first step is to understand complexity. What causes systems to be complex? What is complexity?
By recognizing complexity, you can identify problems before you start investing a lot of time in building systems.
Define complexity
Complexity is anything related to the structure of a system that is hard to understand and hard to modify the system.
Complexity can be anything, it can be about a piece of code that is difficult to understand, taking a lot of effort to implement a small improvement, or difficulty fixing one bug without introducing another bug.
Keep in mind that if you were to write a piece of code and you think it’s simple to understand, but it isn’t for others, then the piece of code you have written is complex. Ask others why they think your code seems complex and help them understand. A developer’s job is to create code that others can also work with.
Symptoms of complexity
The first symptom of complexity is that simple changes require code notification in other locations. The goal is to reduce the amount of code that is affected by a design decision so that when you make a design change, it doesn’t require many code modifications.
The second symptom of complexity is cognitive load, which is what a developer needs to know in order to complete a task. Higher cognitive load means that developers have to spend more time learning which can lead to more bugs because developers could have missed something. There are times when it is better to write more lines of code if it makes it more simple and reduces cognitive load.
The third symptom of complexity is not knowing what piece of code needs modification in order to complete a task. Developers just don’t know what to do.
The most important goal of good design is for it to be obvious. Developers can understand how a code works and what is required to make a change, but only if the system is obvious. Developers don’t have to think hard, and they can make quick guesses about what to do.
Causes of complexity
What causes complexity is dependencies and obscurity.
Dependency is when a code cannot be understood and modified. It’s difficult to remove dependencies entirely since every time you write a new code, you’re also introducing dependencies in the process. The overall goal is to remove as many dependencies as possible and to make dependencies as simple and obvious as possible.
Obscurity is when important information isn’t obvious. An example would be a variable name that doesn’t make any sense and doesn’t contain any useful information. Inconsistency is also a contributor to obscurity.
Complexity is incremental
Complexity is the accumulation of small chunks. Dependency and obscurity build up over time and then lead to complexity.
Strategic vs. Tactical
If you want good design focus more on strategic than tactical programming.
Tactical Programming
In tactical programming, your main focus is to get something working such as a new feature or fixing a bug.
The problem with tactical is that it is short-sighted. You’re trying to finish the task as quickly as possible and as a result, thinking about the long-term isn’t considered. You made just tell yourself it is okay to add complexity if it gets the job done.
This is how systems become complicated. It may look like it’s not a big deal since it’s a small complexity, but it can build up. You can go back and refactor your code, but it will slow down your current task.
Strategic programming
The first step to becoming a good software designer is to realize that just because your code work doesn’t mean it’s enough. Introducing unnecessary complexities isn’t acceptable. Focus more on the long-term structure of the system. Your overall goal should be to produce a great design that also works.
Strategic programming is more of an investment mindset. You invest time in improving the design of the system. It will slow you down in the short term, but in the long term, it will speed you up. Take a little time fixing a small design problem, you will make small improvements that will lead to big improvements.
How much time to invest?
The best approach is to make a lot of small improvements on a continual basis. Recommend spending 10-20% of your total development time on improving.
Startups
Early-stage startups want to get their products/services to customers as fast as possible, so a 10-20% investment isn’t affordable. So startups end up going the tactical route, spending less time on improving design and less on cleanups.
If you lean in this direction, you should realize it would cost more in the long term and there’s a good chance you may not get your product released because of all the bad design choices making the product/service buggy and unusable.
Facebook used to be 'Move fast and break things', but then they change the motto to 'Move fast with stable infrastructure'.
Modules should be deep
Design systems so that developers only deal with a small fraction of the overall complexity.
Modular design
Modules take many forms such as subsystems, classes, or services. Developers work on one module without knowing anything about the rest, but this isn’t achievable. Modules work together by calling each other’s functions or methods. The goal then is to minimize the dependencies between modules.
Think of each module in two parts: interface and implementation. The interface is everything a developer must know in order to use the given module. They must know what the module does, but not how it does it.
The best modules are the ones with interfaces much simpler than their implementations.
What is in an interface?
The interface contains formal and informal information. Formal interfaces are specified explicitly in the code, such as method is its signature, which are names and types of its parameters, type of return value, and so on.
The informal parts are high-level behavior. They can only be described using comments. Informal is more complex and larger than formal.
Abstractions
Abstraction is a simplified view of an entity, which removes unimportant details. They make it easier for us to manipulate and think about complex things. The more unimportant detail removed, the better. However, you must make sure it removes unimportant details and not details that are really important.
Classitis
Most students are taught to break up larger classes into smaller ones. The issue is that it adds to the overall complexity. Developers should encourage to minimize the amount of functionality in each class. Small classes don’t contribute much in terms of functionality. When designing classes and modules make them deep, so they provide significant functionality.
Information hiding
Information hiding is that each module should have pieces of knowledge, which represent design decisions. They should contain details about how to implement a mechanism.
Some examples:
“How to parse JSON documents”
“How to implement the TCP network protocol.”
“How to store information in a B-tree, and how to access it efficiently.”
Include data structures and algorithms that are related to the mechanism.
Information hiding reduces complexity since it simplifies the interface and makes it easier to evolve the system. You should think carefully about storing information hidden in the module.
When designing modules, focus on the knowledge that is needed to do each task.
Information hiding can be improved by making the class larger.
Taking it too far
Information hiding is only useful when the information being hidden is not being used outside the module. If the information is needed outside the module, then you shouldn't hide it. Therefore it is important to recognize which information is needed and which information isn’t. Your goal should be minimizing the amount of information used outside a module.
General purpose modules
The general purpose approach is to build a function that can be used to address a broad range of problems later in the future. The issue is that it’s difficult to predict the future need for a software system, so a general-purpose approach might include functions that are never needed.
The special purpose approach is to build a function that focuses on today's problems. Build what you know you need, and specialize it for how you’re going to use it.
Make classes somewhat general-purpose
The sweet spot is to implement modules in a somewhat general-purpose approach. The module’s functionality should be focused on your current needs, but the interface shouldn’t. The interface should be general and support multiple needs.
The general purpose approach is better than the special purpose because the general purpose can help you save time, will result in a deeper interface, and is a lot more simple.
Questions
Here are some questions to ask yourself that will help you find the right balance between general purpose and special purpose:
“What is the simplest interface that will cover all my current needs?”
“In how many situations will this method be used?”
“Is this API easy to use for my current needs?”
Pull complexity down
You should strive to make life easy for users even if it means extra work for you. It is more important to have a simple interface than a simple implementation for a module.
Taking it too far
You should only pull complexity down if:
The complexity is closely related to the class’s existing functionality.
It results in many simplifications in the application.
It simplifies the class’s interface.
Better together or apart
The most fundamental question in software design is: should functionality be implemented together or separated?
When deciding to combine or separate, the goal is to reduce complexity and improve modularity.
If the pieces of code are similar to each other then it is more beneficial to group them together while if the pieces are unrelated, they are better off separated.
Splitting and joining methods
Splitting up methods might introduce additional interfaces, which can increase the complexity and it can make the code harder to read if the pieces are related. You shouldn’t break up a method unless it makes things simple. The most important goal is to have clean and simple abstractions. Each method should only do one thing and do it completely.
The method should have a clean and simple interface, so users don’t have to think too much in order to use it. Also, the method should be deep: the interface should be simpler than its implementation.
Define Errors
Code that deals with the special condition are much harder to write than code that deals with normal cases.
Too many exceptions
Programmers define too many unnecessary exceptions. They are taught to detect and report errors, which leads to unnecessary exceptions that increase the complexity of the system. Don’t use exceptions to avoid dealing with challenging situations, figure out a way to handle it rather than passing the problem to someone else which results in more complexity.
The best way to reduce the complexity caused by exception handling is to reduce the number of places where exceptions have to be handled.
Define errors out of existence
To eliminate exception handling, define your APIs so that there are no exceptions to handle.
In truth, the best way to reduce bugs is to make the software simpler.
You can also reduce complexity by exception aggregation. Exception aggregation is handling many exceptions with one single piece of code rather than writing distinct handlers for many individual exceptions.
You can think of exception aggregation as replacing several special-purpose mechanisms with a single general-purpose mechanism that can handle multiple situations.
Taking it too far
Defining away exceptions is only useful if the exception information isn’t needed outside of the module. Like in many areas, you must determine if the information is important or not. Things that aren’t important should be hidden, while things that are important should be exposed.
Design it twice
Designing software takes time and is hard, so it’s unlikely that your first design is the best. You’ll be better off if you consider multiple options for each design decision. Don’t just pick the first idea that comes to your mind, consider multiple possibilities.
Also, try to pick possibilities that are different from each other, you’ll learn what works and what doesn’t.
Then afterward you can make a pros and cons list of each possibility.
Here are some questions that will help guide you:
“Does one alternative have a simpler interface than another?”
“Is one interface more general-purpose than another?”
“Does one interface enable a more efficient implementation than another?”
Once you finish comparing, you can identify which one is the best design. If none of the designs fits your needs then see if you can come up with more schemes.
Why write comments?
Comments are what help developers understand the system and can help them work efficiently. If writing comments is done correctly then it will improve a system’ design.
There is design information that can’t be described by code and they need comments in order to understand them. Comments provide additional information that we need in order to work efficiently. Also, it’s much easier to understand comments because they are written in human language, which makes comments simple.
Comments sometimes get outdated, but it isn’t a major problem. Keeping comments up-to-date doesn’t require much effort. Large changes for comments are only required if there is a large change in the code.
The overall purpose of comments is to capture information that couldn’t be represented in the code, such as an idea or thoughts.
Having good comments can reduce the cognitive load on developers since they can ignore irrelevant information more clearly with comments and focus more on important issues. Without comments, developers would have to scan the code and reconstruct what it is. If what they’re reconstructing is incorrect then it’ll just increase complexity, but comments can reduce the unknown since comments clarify the structure of the system and what it does.
Comments should describe things that aren’t obvious from the code
As I said previously, the overall purpose of comments is to capture all the information that can’t be described in code. Comments will record the information from the previous developers and then present it to the current developers, making it much easier to understand and modify the code.
Don’t repeat the code
Many comments aren’t helpful. The reason is that comments repeat the code: information in the comments can be deduced from the code. They don’t provide any value since the code is clear enough to understand without any explanation.
Another mistake is using the same words in the comment that appears in the code. There isn’t any value if the comments are using the same method or variable name. Therefore writing good comments requires using different words than those in the name of the entity being described.
Pick words for the comment that can provide more information about the meaning of the entity such as describing what the entity does, and/or why it does it.
Precision
Lower-level comments are detailed comments that add precision by providing the exact meaning of the code.
Precision is most useful when commenting on variable declarations such as return values, method arguments, and class instance variables. They can fill in details such as:
“What are the units for this variable?”
“If a null value is permitted, what does it imply?”
“Are the boundary conditions inclusive or exclusive?”
Comments shouldn’t be too vague
Higher-level comments enhance intuition
Comments can also argument code by providing intuition. They help readers understand the overall intent and structure of the code. This approach is mostly used in interface comments and inside methods.
Questions that will help guide you:
“What is this code trying to do?”
“What is the simplest thing you can say that explains everything in the code?”
“What is the most important thing about this code?”
Choosing names
Having good name choices makes the code easier to understand while poor name choices increase the complexity of the code.
Create image
The goal is to create an image in the mind of the reader about what is being named. A good name tells us information about what the entity is and what it isn’t.
A name that refers to many different things wouldn’t convey much information to the developer, so you shouldn’t use a name that is used in many different things.
Write comments first
Write comments at the beginning of the process, as you write the code. Don’t just wait until the end of the development process when all coding and unit testing are finished.
Modify existing code
You would want to take a strategic approach when modifying existing code in order to maintain a great design. Don’t rush in order to get things done quickly. Think about if the current design is still the best one after the desired change. If it isn’t then refactor the system in order to produce the best possible design.
When modifying existing code, find a way to improve the design even just a little bit. If you aren’t making the design better, you’re most likely making it worse.
Maintaining comments
When you change the existing code, comments would become invalidated. Therefore comments must be kept up-to-date with the latest information. The best way to keep comments up-to-date is by putting them close to the code they describe so that when developers change the code, they can see the comments and modify them accordingly.
Also, remember that comments belong in the code and not the commit log. A developer who needs information isn’t going to be scanning the repository log. Even if they do, finding the right log message would be tedious.
Consistency
Consistency means that similar things are done in similar ways. Once you have learned something in one place, you can apply it to another place. If it isn’t consistent, developers would have to learn about each situation differently.
Taking it too far
Don’t try to force dissimilar things into the same approach, such as using the same variable name for different things, you’ll create confusion and complexity.
Conclusion
Complexity is what makes a system hard to maintain and build, and it sometimes slows them down.
All the suggestions presented require extra work in the early stages of the project. However, the investments you make will pay in the long term if you were to apply these suggestions such as your skills and experience would improve and you would spend less time staring at the screen.
[END]