Deconstructing SOLID design principles
I got myself into this book writing project in part because I went looking for a specific kind of design book and… didn’t find it. If you can’t find it, it’s now your job to create it, right?
Among the successful design aesthetics out there is SOLID (and that’s a link to a pdf of an article introducing many of the ideas, btw). What I’d like to do today is “critique” it, in a sense. Or perhaps, deconstruct and re-evaluate it, in light of the things I’ve been talking about on this blog for awhile. Why isn’t this the kind of stuff I was looking for?
What’s context??
One of the big concepts I can’t shut up about is system boundaries. The reason I can’t stop talking about them is they’re #1 on the list of considerations when it comes to that nebulous “context” and “trade-offs” thing people sometimes hand-wave about. If we’re going to talk shop about design, they’re going to come up constantly.
You can’t talk about what designs are good without considering whether you’re working on a system boundary or not. We must design system boundaries differently than just any other code.
So if you’ve got a list of things that constitute “good design,” and they do not give even the slightest consideration to system boundaries… Well, you start to see where I raise my eyebrows.
Let’s dig in.
S: “single responsibility”
“A module should have only one reason to change.”
This is the most confusingly stated principle in SOLID, in my opinion. I’m not sure many people have a good idea of what it means.
In my post on modularity, we look at what a module consists of. I ended with a note about what I missed, and perhaps should return to with a full post of it’s own… But I digress, here’s the short version:
We often think the important things about a module are what abstractions it exposes, and what dependencies it has. But the two most important questions about a module’s design are:
- What must this module NOT expose?
- What dependencies must this module NOT have?
- (Which creates an implied third:) What other modules must NOT depend upon this module?
More on this in a future post (probably), but this all goes back to an old design classic paper, “On the criteria to be used in decomposing systems into modules.” The underlying idea: find an assumption that might change, and encapsulate that assumption into a module. Then, when it does change, only that module will be affected. This makes changes easier.
The “single responsibility” principle is getting at this same idea, but I think it confuses things. There’s sometimes not a single “responsibility” by itself, and there’s really little reason why exactly one should be the goal. (Especially if we’re conflating, as OO advocates sometimes do, “module” with “class.” Good module designs often encompass multiple classes.) I kind of suspect this phrasing was chosen so it would fit the SOLID acronym.
But there’s potentially another reason for this phrasing, and that’s the common “god-object” dysfunction in object-oriented design.
This is, pretty specifically, an OO dysfunction (there no functional programming equivalent that I know of).
I suspect it stems a little from the “is-a” relationship idea.
My User
class is a user, right?
Okay, so, authentication goes there because a user authenticates.
Okay, so, sessions go there because users have sessions.
Okay, so, sending messages go there because users send messages.
And so on, to oblivion.
This leads people to break all of the good rules about designing modules, while doing things that seem completely natural and reasonable at the time.
But then User
knows about everything, everything knows about User
, and you’ve got spaghetti.
This is, as I see it, what “single responsibility” is all about. It’s a principle named after a specific OO design dysfunction, but the actual “rules” are really about good module design.
O: “open–closed”
“A module should be open for extension but closed for modification.”
Last year, I used the expression problem to show the different choices we have in type design. When we introduce a type, we have a choice between three good options: data, objects, or abstract data types (ADTs). Each of these come with different trade-offs, different modes of extensibility and future maintainability, and are appropriate in different situations.
And SOLID is here to tell us pretty much “DATA BAD, ALWAYS USE OBJECT.”
Or at least, that’s definitely what I take away from reading its authors. As you can imagine, this is a principle I don’t like at all.
The examples used are always “if we use data, we can’t extend it with new variants, but if we use an interface, we can!” I hope that, having read my piece on type design, people immediately have a problem with that reasoning. After all, now the interface has a fixed list of methods, and that type cannot be extended with more methods without modifying the original module! Shoot! The example of being “open for extension” is actually “closed for extension!”
But there’s another serious problem here, and that’s the implicit assumption that extensibility is desirable. One the one hand, we shouldn’t forget that ADTs are a legit design choice, and are deliberately (and desirably) not extensible by users (and as a result, become safely modifiable by maintainers). I even speculate that ADTs are probably more commonly the right type design choice than interfaces!
The bigger issue, though, is the thing I can’t shut up about. Let me quote myself on design goals:
Extensibility and re-usability are potential goals for system boundaries only.
Being “open for extension” is blanket advice that ignores all context. The open-closed principle tells us everything should be extensible, but extensibility is for system boundaries, and we really shouldn’t create system boundaries unnecessarily! Boundaries are expensive. Code that’s not on a boundary is cheap and easily modified. If it’s cheap to modify, it doesn’t need to be extensible, and making it extensible is usually a step backwards in design quality.
We’re much better off designing for easy modification wherever it’s cheap to do so. You even might say we should be “open to modification.”
L: Liskov substitution
“Subclasses should be substitutable for their base classes [without breaking behavior contracts].”
I don’t object to this one at all. Mostly, I object to the casualness with which this principle gets thrown around. I don’t think advocates of SOLID always fully understand the implications, because implementation inheritance routinely violates Liskov substitution (especially when taking variance seriously).
Confronting this reality leads one down the path of “preferring composition over inheritance,” all the way to banning implementation inheritance (almost?) entirely. That might be a good goal, but I’m not sure everyone understands it’s a consequence of this principle.
I: interface segregation
“Many client-specific interfaces are better than one general-purpose interface.”
To be honest, I don’t really understand this one very well. Or rather, I think it’s pretty vague and has a few interpretations, which means it’s not exactly one good principle.
One simple interpretation is that “god-interfaces” are just as potentially destructive as “god-objects.” If you’ve read my bit on modules, you can imagine a little bit why: interfaces can create new public dependencies in a module just as well as classes can. A better design is one that controls and limits their scope. So multiple interfaces that allow some public dependencies to be avoided when they’re not necessary can be quite a bit better than a single mega-interface. (I do think thinking in terms of public dependencies is critical here, though. Otherwise we might be left wondering why multiple smaller interfaces are supposedly “better” and when to actually break things up.)
Another interpretation, as best I can see one, concerns what we often see happen with REST APIs, or with plug-in systems, that need to evolve while supporting legacy code. This is a principle that exclusively applies to system boundaries. In this situation, we’re stuck with an interface we can’t change, because it’d break users. So instead of changing it, we introduce a new, “version 2” of the interface, and modifying our code to work regardless of which is actually used.
This allows all the legacy users to be unaffected, while allowing the design to (partially) evolve. It’s only partial, though. Often, having to support the exact behaviors available through the legacy interface makes the most-desired change infeasible. We’re often forced into some compromise.
So this isn’t so much a design principle as it is a tool for coping with hard system boundaries. It’s a tool we’re forced into using, not something we should ever be applying from the start. Except that if we’re to apply this tool, the design needs to be amenable to its use. But this mostly just boils down to being cognizant of what we’re exposing as a system boundary at all.
D: dependency inversion
“Depend upon abstractions. Do not depend upon concretions.”
This is one of those principles that I think is partly backwards, again. Like the “law” of Demeter, this rule, if followed, encourages you to think about how you’re using code. The correct thing to do here is think about how that code is structured, instead.
Your task is not to “not use concretions.” It’s to not expose the “concretions” that should not be used.
And again, we’re only talking about system boundaries, although in this case, even “soft” system boundaries (of other modules) are in scope, too. As I mentioned in the Demeter post, the real law here is that “anything exposed by a system boundary equally becomes a system boundary.” Don’t get caught unaware.
I also continue to object that many “concretions” are actually abstractions. Advocates of SOLID seem to take the view that only interfaces are abstractions, but any type is, including ADTs and data.
The “inversion” this principle mentions comes about as a result of the old “interfaces cutting dependencies” trick. When you’re trying to hide a dependency (because design demands that a module not depend on another one), an interface can sit in the middle.
So this principle is a bit of a muddle of a few different ideas, and I think thinking in terms of system boundaries (and having a reasonable notion of “module” besides just “class”) is a better approach.
End notes
Today’s post is way long enough, but I also think it might be suffering from a serious lack of examples. For today, oh well. Sorry. :)