DEV Community

Sam Fisher
Sam Fisher

Posted on • Updated on

A Philosophy of Software Design, Part 0: Introduction

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.

The Idea of Good

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.

Design Principles for Good Software

(1) Simplicity

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.

(2) Modularity

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.

(3) Minimality

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.

(a) Self-Similarity

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.

(b) Orthogonality

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.

(c) Generality

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.

(4) Composability

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.

Towards a Process for Good Software

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.

An Outline for our 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.

Inspirations

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

Special Thanks

Andy Pickle, for convincing me to write on dev.to
Ry Whittington, for pointing out a crucial typo (see discussion below)

Top comments (6)

Collapse
 
rwhitt2049 profile image
Ry Whittington • Edited

Solid article. Sometimes I try to point people to SOLID, but that can be a little obtuse for new programmers.

My only critique would be your section on orthogonality. Orthogonal means being at a right angle to each other (in the geometric sense), or completely independent of each other (in the statistical sense), and not parallel. You are 100% correct on feature independence being key.

In the context of software features, it has more to do with the single responsibility principle and the separation of concerns rather than simplicity. Although simplicity is often the natural outcome. Wikipedia has the technical CS definition, but it went over my head from some time.

The way I usually explain orthogonality is with the following example. Say you have a smart phone with two features: one feature to change the volume, and another feature to change the screen brightness. These features would be orthogonal because you can (hopefully) change the brightness of the screen and have no effect on the volume, and vice versa.

If these features were not orthogonal, you might increase the brightness and then also increase the volume equally. How obnoxious! Orthogonality leads to the minimization, or complete eradication of side effects.

Good start! Looking forward to the next parts so I can share with my junior colleagues.

Collapse
 
swfisher profile image
Sam Fisher • Edited

Thanks Ry! Good catch on orthogonality. I meant to write “perpendicular” but instead wrote “parallel.” I think orthogonality has a really potent analogy to simplicity in problem decomposition through the search for a “minimum dimensional basis” for a set of vectors from linear algebra. I don’t quite know how to say it in an accessible way yet.

The low dimensional basis idea suggests that we should not only search for some collection of simple, seperate subproblems, but actually aim for the most compact set. Perhaps this is another principle (compactness) in itself?

As I see it, the single responsibility principle and seperation of concerns are ways of guiding us back towards the criteria of simplicity and orthogonality, respectively. Orthogonality directly follows from simplicity because it offers a way from complex (many folded) to simplex (one fold).

Collapse
 
beenish_rana profile image
تبدیلی ریجیکٹڈ

Great Read...!!!
When is the next part of this series coming up?

Collapse
 
swfisher profile image
Sam Fisher

Thank you! Appreciate you reading it. About time to get around to the next part, eh? Right now I’m juggling full time work + grad school, but this summer I’ll see about getting the whole series out. :)

Collapse
 
pickleat profile image
Andy Pickle

Love it Sam! Can't wait to read the other parts.

Collapse
 
swfisher profile image
Sam Fisher

Thanks Andy! I'll keep churning them out.