Note: This series of articles is a living document in a continuous and unpredictable release cycle. I modify it when I have time and inspiration. It serves the purpose of helping me understand and share the ideas that are primary to my understanding of our craft. Feedback will definitely be considered and appreciated.
"The right question at the right time leads to a natural and vigorous effort." - George Polya
As software engineers, we'd love to be in an endless flow of movement towards precisely the right outcome at each point in time. We'd love to enjoy our work fully, to deliver great products, and to consistently grow as craftspeople. But how do we ask the right questions that inspire the right actions at any given point in time? How do we structure our effort?
We don't really know the answer to these questions, no matter what tools or principles we use to help us navigate. Some such ideas are written down and understood; some are just subconscious tricks we aren’t even aware of.
As generations of software engineers have reflected on their work, a handful of global strategies for software design have emerged and been made explicit. The code of these strategies reflect universal principles of mathematical problem solving and have meaningful analogs in all branches of science and engineering.
Masterful practitioners tend to know these strategies on an intuitive level. For some it can be hard to verbalize the tools that are well-integrated into their body of tacit knowledge. This means that beginners can be left grappling for useful language about why particular software designs are good or bad.
As beginners, we can seek to integrate good design principles into the intuition that guides our everyday practice. Having an explicit verbal representation of such principles is only a pointer towards developing insight. Ultimately, it is your intuition that has to do the work.
As a whole, designing software is about studying the natural structure of abstract problems and expressing that structure simply and beautifully. The question is always "how do we reach a simple understanding of the problem and reflect that understanding in the design?" But why simple?
The closed and predictable nature of software always exists within an open and changing human context. The criteria for simplicity emerges because software succeeds or fails by its ability to gracefully manage human limitation.
Is the codebase intuitively beautiful to programmers?
Could we easily integrate its functionality with a new idea?
Can we easily change how a part of it is implemented?
Does the system perform seamlessly for end users?
Is the system's functionality immediately understandable to end users?
And so on...
The following ideas should help us with all of these objectives at once.
Simplicity, in this context, means focus on one thing. One fold. One problem. One responsibility. Simple ideas fit easily and compactly within our cognitive limitations. Reaching simplicity enables you to reason about the problem with fluency and ease. When we understand a subject well, our understanding becomes simpler. This being said, simplicity takes effort.
What is complexity? Let's decide that it means focus on many things. Many folds. Many problems. Many responsibilities. This is a convenient definition for us. It's clear that a complex thing is actually composed of many simple things.
Divide any complex problem into a set of small ideas that most easily and naturally express the complete picture. In programming, each small problem is often solved by one "module" of the system. This has myriad benefits, but most prominently it allows you to focus on one aspect of the problem at a time. As a problem solving strategy, this is sometimes called "Divide and Conquer."
Allow each module to take on its own identity rather than relying on an external context to make sense. A square is just a square. A random string generator is just that. Deliberately avoid entangling the modules with their context, as this creates complexity. This strategy is called "decoupling", or taking apart what was once tied together.
Use good names for your modules that clearly distinguish what they are. These names will serve as the one word summary for calling that module to mind. Creating simple names for meaningful groupings is called "Abstraction." By creating a word for something, we can stand back and describe it from a bird's eye view. We now have a nice coat-hanger in our memory that we can use to recall the details when we need to.
There are many possible collections of simple modules we could use to solve a complex problem. How do we choose a good collection?
We choose the smallest set of modules that result in the broadest set of functionality. In other words, we search for maximum leverage with minimum effort. We'll call this ideal state "minimality." A successful modular approach to reaching simplicity has minimality. Minimality is achieved by discarding complexity in our understanding of the problem without losing information.
Very often, problems contain hidden redundant information. Consider the way shapes that are symmetrical can be divided into two identical sub-shapes. When self-similarity in the problem exists, you can always abstract away the unchanging aspects to simplify your understanding.
This often arises in programming in the form of 'recursive objects' like sets, arrays, graphs, strings etc. All of these common structures are made up of smaller instances of that structure and have a smallest possible version. When a recursive structure or algorithm is chosen, a programmer looks at the problem and sees the self-similarity echoing through the complexity. For a classic example of a recursive solution, check out the "Tower of Hanoi."
Another way self-similarity expresses itself in computer programs is the folding of unchanging factors in a calculation away from program logic and into data. This is called proportionality. If some aspect of a computation is the same for every element in an iterative process, it makes sense to abstract this away. This has the dual effect of making the program easier to reason about and faster to run.
Lastly, we can know that the language and tools we are applying to the problem are unnatural and unhelpful if they do not immediately suggest meaningful self-similarity. There are infinite ways to divide and conquer a problem, and if our strategy does not reflect the natural shape of the problem then it will tend to obscure rather than reveal the inherent recursive structure living inside it. Finding and honoring self-similarity in a problem leads to "a-ha" moments because it brings us directly to simplicity.
Separation is how simplicity expresses itself in problem decomposition. When two spaces are orthogonal, it means that they are perpendicular to one another and therefore have only a single point of intersection.
Orthogonal entities can evolve independently from one another. They also have perfectly minimized redundancy in information since they have nothing in common. Finally, they have maximal combined “bang for buck” because two independent things can be combined in more ways than two dependent things.
As a rough but fun analogy for this concept, consider that I can do more with a cup and a knife than I can with two cups. Cups and knives both have useful functionality, but the cup and the knife have much less in common with each other than the two cups.
Imagine that instead of an orthogonal cup and knife, I designed a cup and knife hybrid. My cup-knife will be full of compromise between disparate concerns. It will be less effective at facilitating both cutting and drinking. At best, it will represent two unrelated bodies of functionality uncomfortably sharing space.
This entanglement creates unnecessary complexity.
The orthogonal modules you design should also be simple. A simple module will have a natural clarity of expression, testability, and reusability because of its singular focus. If a module in your design turns out to be complex, you can reduce it into two or more orthogonal pieces. These will be easier to create, evolve, and connect than two entangled pieces.
Can we broaden the problem we're solving without adding dimensions to it? Does taking away unneeded details help it to take on a clearer form? Generality is achieved by such insightful subtraction and summarization.
We started out by thinking very close to the problem's nature. This might leave us with a design that reflects a narrow case of a more general problem. If we allow our model of the problem to cast off the trappings of the domain in which it arose, we can achieve functionality that exceeds the initial expectations while growing simpler in its nature. If the scope of work grows when you try this, then you've probably added an extra dimension to the problem. If it shrinks, then you've probably removed some arbitrary specificity from the model and moved towards minimality.
All these tidy, independent modules we're creating need to communicate well or else they won't help us solve the problem. Additionally, we can gain a tremendous amount in generality if our modules can communicate with other systems we haven't thought of yet. Clean interfaces for your modules allow them to accomplish both of these goals.
Composability is the measure of how simple it is for any given system module to act on the output of a different module.
Optimizing for composability involves tradeoffs in coupling and decoupling modules. The design of REST, a style of networked application architecture, constrains an API to have a uniform interface for any and all client applications. This creates huge gains in composability at the cost of some performance optimizations that might have come from tightly coupling the interface to a client. The minimum we can do is to take free gains in composability whenever they are available. The maximum we can do involves compromise with other desirable qualities.
When possible, favor simple and universal formats for input and output. Provide the least surprising developer experience when interacting with the module. Use names that succinctly describe each module's point of singular focus. Good names are a starting point for code that makes your understanding of the problem immediately visible.
Where does that leave us? We've identified some core principles of good software design, but these principles only go so far in telling us explicitly how to structure our time and effort.
In my view, creating software is a discipline of problem discovery and decomposition. In the coming articles I'll lay out a fairly universal model for designing software based on the principles discussed above. I'll derive the skeleton of the process from a generalized problem solving approach known as "Polya Problem Solving", outlined in George Polya's book "How to Solve It." Each article will tackle one stage of the process.
1) Understand the Problem - Discovering and defining requirements
2) Design a Solution - Articulating a system design
3) Implement the Solution - Partitioning work intelligently
4) Learn from Reflection - Using self assessment and extension
5) Learn from Contact with Reality and People - Using measurement, delaying optimization, and adapting to surprises
6) Iterate Forward - Maintaining robustness and scalability inside of a changing context.
Each phase has a risk of being incomplete or going on for too long, and requires good heuristics for deciding when to move forward. More formally, each phase transition in our sequence is an instance of an optimal stopping problem. The work is usually never perfect and never done.
Next up, we dive into Part 1: Understanding the Problem.
The Art of Insight in Science and Engineering - Sanjoy Mahajan
The Art of Unix Programming - Eric Raymond
The Pragmatic Programmer - Andrew Hunt and David Thomas
Architectural Styles and the Design of Network-based Software Architectures- Roy Fielding
Grokking the Systems Design Interview - Design Gurus on Educative.io
How to Solve It - George Polya
A Beautiful Question - Franz Wilczek
Andy Pickle, for convincing me to write on dev.to
Ry Whittington, for pointing out a crucial typo (see discussion below)