Writing technically proficient code on a line-by-line basis is all well and good, but paying attention only to the micro aspect of our code is a recipe for disaster. But oftentimes that’s what we do, especially as beginners. When we’re just starting out, we struggle with learning the tools, jargon and syntax so that we can start coding, dammit! But once we’ve mastered variable declarations, conditionals, and loops, it’s time to start thinking about our code more holistically.
WHY do we need to think about the architecture of our code?
It’s an oft-statued truism that our code will be read by other humans more often than it will be read by machines. This observation is meant to nudge us towards writing code that’s friendlier for human comprehension, even at the cost of leaving some efficiency on the table. You can probably find edge cases where this generalization doesn’t hold, but for our purposes, we’ll assume that you’re convinced that it’s important for your code to be comprehensible to your human colleagues and easy to maintain.
HOW do we think about the architecture of our code?
It may be disappointing to learn that there is no one accepted best practice approach to writing all code, ever. But this is actually a blessing as we want our rules to be concrete enough to be useful, but flexible enough not to drive us crazy as we try to fit all possible situations into a handful of paradigms they weren’t designed for.
We think about code architecture in a way similar to how we think about designing a lot of other objects. After all, code is an object, even if it sometimes feels more ephemeral because it’s living inside of another machine. Code design or architecture (there’s not much of a difference in this case) is about how we build up individual lines of code into modules (or cohesive units), what it even means for a unit to be cohesive, and how our parts fit and move together. How our modules depend on each other and how they communicate is a huge part of design thinking.
Especially if you’re used to writing mostly self-contained, brief scripts, beginning to think about having an intentional design is important. One, because even short scripts should be intentional. And two, because it will allow you to grow into solving more complicated problems.
In this post, I’ll give a high-level intro to the SOLID principles, which were collected into one philosophical paradigm by Robert C. Martin beginning in the early 2000s, based on work that he and others had been doing since the 1980s. They were given the monicker SOLID principles in about 2004 by Michael Feathers.
The SOLID principles
Before diving into each of the principles, a few words about design thinking. First, if you’re skeptical, then it’s probably more rigorous than you might expect. Second, if you’re afraid that your creative genius will be hampered by having to follow rules, you may be pleasantly surprised to find that design thinking can actually increase creativity by allowing you to focus on the truly novel part of your problem by taking care of some of the basics.
When talking about the SOLID principles, reference is made to classes and modules. This does not mean that they apply only to object-oriented programming, though they do work very well with OOP.
Single Responsibility Principle (SRP)
This principle is often stated as:
A module should have one, and only one, reason to change.
There are a lot of misleading things in that statement. Where it says module, we can interpret that to mean not only a source file (though that also works), but any cohesive set of functions and the data they operate on. What makes a set of such functions and data cohesive? Well, that’s a bit of circular reasoning here since abiding by the SRP can be what makes a set of functions and data cohesive.
The idea of “only one reason to change” is also misleading as it might imply that our module is only allowed to do one thing. While that’s true of functions, it’s not true of sets of functions. Collecting our functions, e.g. in a class, would not make any sense if every class were only allowed to do one thing!
No, what the SRP actually means is that each module should be responsible to a single actor, and only that single actor can force the module to change its behavior, e.g. by adding in functionality or changing an implementation. (I’ll use only the word “module” going forward for brevity, but feel free to substitute in “cohesive collection of functions and the data they operate on” every time.)
Let’s make it concrete with an example. Suppose you have a machine that dispenses beverages. Now say the requirements change because either:
1) Consumer tastes change and people are no longer interested in soda, so the machine now has to dispense ten different flavors of LaCroix sparkling water instead (yum).
2) The labeling on the outside of the machine needs to change because the art department changed their branding.
The first change is about content. The second change is about presentation. We might imagine that our VP of Product asks for the first change, but the VP of Marketing would ask for the second change. Those are two different reasons why our beverage-dispensing class might change. Since the changes are being asked for by two different actors, each of those behaviors should be in their own module per the SRP. That is, each module can be responsive either to the VP of Marketing or the VP of Product, but not both.
This principle comes from Bertrand Meyer in 1988 (how things change and how they stay the same…) and states:
A software artifact [again, think source file, module, class, or function] should be open for extension but closed for modification.
Yikes, that sounds like a really demanding principle: Closed for modification? Does that mean all of our code might as well be written in stone? Not quite. What it means is that we should be able to add functionality without having to rewrite everything from scratch.
Imagine if every time requirements changed, all existing code were dumped into the garbage and the team started over. That would indicate that there was insufficient attention paid to the principles of good design at the start!
This all mostly comes down to making sure that our dependencies between modules have the right direction. In our earlier example of the beverage dispensing machine, say that now we also want our machine to dispense chocolate in adding to LaCroix. We shouldn’t have to change anything about how it dispenses water in order to also add in the ability to dispense chocolate. That’s the open/closed principle.
I’ll write in a later post about the difference between interfaces and abstract classes in OOP design and how it relates to the open/closed principle, but even without all that OOP jargon, we can get a high-level idea of what the OCP is saying. Hide information properly so that a change in one requirement doesn’t require everything to be rewritten.
Liskov Substitution Principle
Another gem from the 1980s, the Liskov Substitution Principle is named for Barbara Liskov (badass woman computer scientist who was one of the first women to be granted a doctorate in CS in the U.S.). The principle is often stated in more technical terms, but a friendlier description comes from 1988:
If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.Barbara Liskov, “Data Abstractions and Hierarchy”, 1988.
Whew, that’s a lot of words. The easiest way to understand it is by looking at the canonical example of how the principle is violated. Warning: bad design practice coming up, don’t ever do this! But say you were to design a rectangle class, and then make a square a subtype of rectangle.
If we’re being generous, we could say this isn’t bad on its face. Rectangles and squares have a lot in common, and it might seem that squares are just a refined kind of rectangle, so we can make square a subtype of rectangle. But this violates the LSP because square isn’t actually a true subtype of rectangle: if our client is expecting a rectangle, but we give it a square instead and it tries to do something like set height and width to be different values, 💥 . Oops.
In this case, square isn’t a true subtype of rectangle because the program P’s behavior could not go on unimpeded when we gave it a subtype when it was expecting the original type. To avoid these kinds of collisions, we must make sure that our subtypes are truly subtypes, and that’s the LSP.
Interface Segregation Principle
This principle can best be summarized using a chart from Martin’s book on architecture:
What we have here is a class calls Ops that has three clients, User1, User2 and User3. All three clients use the Ops class, but they don’t have anything to do with each other. Say User2 has a spec change that requires changing Ops; well now User1 and User3 might also be affected by this change in Ops, even though they should theoretically be insulated since they don’t ever interact with User2! Oops.
The solution comes from the name of the principle: interfaces. Martin says that we can solve the issue by adding in a layer of interfaces between User1/2/3 and Ops, called, e.g., User1Ops, User2Ops and User3Ops. All these UserOps are interfaces that themselves inherit Ops, so that changes in Ops aimed at changing User2 don’t involve User1 or User3.
Although the principle uses the word “interface,” this isn’t only about literal interfaces in Java or abstract classes in C++. The principle applies in general to all systems or modules where you don’t want your code to depend on things it doesn’t use. In the extreme case, such dependency will cause a lot of recompiling and redeploying. But even in interpreted languages where this isn’t an issue, the logic will be unnecessarily complex and can lead to errors if there’s tight coupling between things that don’t need to depend on each other.
Dependency Inversion Principle
This principle is a bit hard to describe without going too far into OOP jargon about interfaces and abstract classes (maybe a topic for another post?!), but we’ll try. It’s often stated as:
High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions.
Or, source code dependencies should refer only to abstractions, not to concretions.
In short, it cautions against depending on concrete implementations that might be volatile. This dependency can be alleviated by using interfaces, which are basically promises made by a module to its client saying, this is the behavior you’re allowed to use, without guaranteeing how that behavior is implemented.
The lowest level of the code should refer not to a concrete class whose implementation might be volatile, but to an abstract interface that we should try hard not to change, even if we need to add functionality.
The inversion in the name of the principle comes from the fact both our client and our concrete implementation point towards our interface / abstract class. Normally the flow of things would be from high-level (client) to low-level (implementation). But we invert this by having both our high (client) and low (implementation) levels refer to the interface/abstract class.
As we’ve said before, the whole point of thinking about architecture upfront is to prevent cases where a new specification causes us to throw out everything we have and start over. The principles aren’t intended to be always binding, and there might be cases where it makes sense to break them. But as with all grammars, learning the grammar is the first step towards having the skill to violate it when necessary.
Data scientist at Metis. Previously a risk consultant at McKinsey & Co. I have a master's in public policy and econometrics from the University of Chicago and a B.A. in economics and political science from Northwestern University. I'm currently working towards a master's in computer science from DePaul University in Chicago.