Designing good software can be challenging. Yet, at its core, good software design is fundamentally about two things:
- Problem decomposition which refers to taking a complex problem and breaking it into sub-pieces that can be solved relatively independently and
- Minimizing complexity which refers to writing software in a way the developer and others can easily understand and improve the system being created.
As Ousterhout (2018) argues in his book ‘A Philosophy of Software Design” when addressing the problem of how to design good software we should go beyond traditional discussions about programming techniques and tools (e.g., object-oriented programming, design patterns, and algorithms) or project management approaches (e.g., agile management, version control) in order to dig deeper into the skills that make the so-called “10X programmers” an order of magnitude more effective than average programmers.
A complex system is one with several interrelated parts whose behavior is difficult to predict, understand, or change. At a high level, complexity manifests itself in decreased velocity or agility, inefficiency, super-linear scaling of resources (time and financial resources), increased instability (bugs, failures), increased uncertainty and unpredictability (lack of confidence/intuition) and overall developer discontent and frustration.
At a lower level, some manifestations of complexity include:
- Change amplification leading to decreased velocity or super-linear scaling in resources: A common example would be a single logical change requiring touching the system (code modifications) in multiple places.
- Cognitive load leading to decreased programmer productivity and potential bugs: A good example would be the amount of information a developer has to consider to complete a task; in other words the preparatory work needed before doing actual work.
- Unknown unknowns leading to increased uncertainty and lack of predictability: This is most evident when the developer is left wondering whether they truly understand the impact of a change and are concerned about implicit dependencies.
Ousterhout (2018) asserts that complexity derives from dependency and obscurity amplified by time, size, and change. A dependency exists when one piece of the system (code) cannot be understood or modified in isolation; it relates to another part of the system that also needs to be considered or modified. There are both explicit and implicit types of dependencies. On the other hand, obscurity happens when an important piece of information is not obvious. It can occur in two ways:
- When clarity and documentation are not prioritized at the time of development (e.g., generic variable names or insufficient comments),
- When the design gets bigger and harder to keep track of everything in mind, leading to non-obvious design choices and subtle side effects.
Complexity compounds over many incremental decisions, each of which may be innocuous in isolation, yet together create an insidious bigger problem. Complexity also arises from the iterative and extensible nature of the design process. It is harder to keep a design simple when changing it on the fly in production, working under time pressure; these discourage refactoring for simplification because developers are worried about breaking existing working functionality.
Simple designs with a problem decomposition that is well considered to enable the software programmers to build larger and more powerful systems and do that faster and cheaper, in other words, they enable sub-linear scaling. They also improve developer productivity, enabling more time on design and features, and less on bugs, and improve predictability, making the overall process fun. This, in turn, helps to hire and retain top talent, creating a virtuous cycle.
The answer to avoiding complexity is simple although it might not be easy at all times:
- Eliminate complexity when possible.
- Encapsulate complexity effectively.
- Be disciplined about repeating these approaches across the lifetime of the design.
One of the main principles of software design is to eliminate complexity and document as you design. Yet, why would such an approach of document-as-you-design work?
A design culture that prioritizes the design documentation as a first-class activity will avoid obscurity which is a major challenge for overcoming complexity. The purpose of good software design is to make the code and design decisions obvious which is the opposite of obscure. So, the process of writing good comments if done correctly will improve a systems design, and conversely, a good system will lose a lot of its value when poorly documented. Well-written comments can help reduce all the signals of complexity such as change amplification, cognitive load, and uncertainty around unknown unknowns.
On the other hand, there are many misperceptions about writing comments. The cliche that “good code is self-documenting” is misleading as crucial design information cannot be captured in code. Comments provide a means for abstraction and making the informal assumptions of interfaces explicit. Having no time to write comments is another short-sighted approach as it pays for itself in increased productivity although it may seem as overhead at first. Over time, this overhead gets reduced even further, as comment writing becomes natural. In addition to this, getting out of date would be misleading for comments as it reflects a non-disciplined approach to documenting, as well as a lack of understanding of best practices like keeping comments close to the code.
There are several best practices that when adopted make for effective documentation and in turn improved design productivity. At a high-level, to write good comments, one would need to put himself or herself in the position of an individual being exposed to the code for the first time.
While higher-level comments for methods can provide intuition on what is the code trying to do, lower-level comments on variable declarations can provide precision on units, boundary conditions, and invariants. Clarity and consistency are also important to avoid complexity. Variable naming is an example of incremental complexity as clear variable names make code easier to understand and detect errors. In addition to this, being consistent in naming and comments can reduce cognitive load and facilitate knowledge reuse. One should ensure that all variables with the same name have the same behavior and use distinguishing prefixes otherwise (e.g., srcFileBlock, dstFileBlock). Consistency about loop variable conventions (i = outermost, j=innermost, etc) is also important.
Comments apply to multiple elements of design such as class headers, class variables, method headers, method implementations, and cross-module design. Good comments reduce the number of code people must read. They clarify design abstractions, describe things that are not obvious from the code, and highlight subtleties.
The discipline of writing comments helps to identify problems early and improve the design. The basic practice should be to first write class header comment for every new class, then to write the signature and comments for methods and then to write declarations and comments for key variables. The bodies of methods should be filled in by adding implementation comments as needed. During iteration, comments should also be updated whenever necessary.