Not so SOLID

March 23, 2021

I often don’t agree with Bob Martin.

It’s not that I don’t think he’s smart or capable or has had some good ideas; it’s really that I don’t see eye-to-eye with him on most things. For example, Test-Driven Development, to me, is a crazy backwards upside-down religion, not a defensible evidence-based set of techniques for ensuring you produce good software.

A couple of years ago, I was looking for a job, and one of the interviewers asked me if I knew the SOLID principles. I did, but not in the way they wanted to hear it. They wanted to hear me say, “Yes, ‘S’ stands for single-responsibility, ‘O’ for open-closed, ‘L’ for Liskov substitution…” They wanted me to regurgitate the acronym, because as cargo-cult programmers, they took SOLID as a message from the gods.

But it isn’t. SOLID is just some guy’s opinion. And today, I’m going to tear that opinion apart.

S

Let’s look at that S. It’s innocent enough. “Single-responsibility!” This is arguably the least controversial part of SOLID, since it’s really a variation on “Keep it simple, stupid!” When you make a piece of code, don’t make it do more than the one thing it’s supposed to do! But this rule really applies not just to classes, but to functions, to modules, to libraries, and to virtually anything else you make. Each piece of code should do one thing and do it well. Pick that one thing for it, make it do it, and keep everything else out of there.

Make everything as simple as possible, but not simpler,” as Albert Einstein has oft been paraphrased.

Of course, Zawinski’s Law will always hold, but a major part of your job as a programmer is to fight against unnecessary growth. Software will almost always grow, but don’t let it grow like a cancer, unpredictably and uncontrolled: Don’t just glue another feature on the side of existing code. If the feature is sufficiently different, add it where it belongs: Another file, another module, another class, another library, another program.

O

But now we get into trouble with O. The “open-closed principle?” This is an opinion that you should make things that are “closed for modification but open for extension,” and I absolutely, absolutely disagree.

You should make things that are closed, period, wherever possible. Open for extension means, in object-speak, that I can inherit your class, and add new pieces, or swap pieces out where you’ve allowed me to, and this is bad, because inheritance is evil and dangerous.

But why is inheritance so dangerous? A million coders everywhere have used it for decades; it can’t possibly be all that bad, can it? (Just remember that they used to say the same thing about goto.)

The answer is that inheritance is a tight coupling between two pieces of code. If you use inheritance, and you need to change a parent class’s behavior, you can’t change it without affecting every descendant class. That might be fine if you control those descendant classes, but if you don’t? Those unknown couplings make changing the parent impossible. There are design decisions that Microsoft made in the base Control class for WinForms twenty years ago that I’m sure they’d love to go back on, but they can’t change anything because a million other classes inherit from it, all over the world.

Inheritance violates encapsulation. It doesn’t matter how well you scatter private and protected over your code: It’s a tight coupling, and every tight coupling eventually causes trouble. Encapsulation — good encapsulation — is the only thing that will keep your hair from being pulled out in any sufficiently-large project, and if you want it, seal your classes, final your classes, immutable your data, and do every last thing you can to keep other people’s grubby mitts out of it.

L

I’m pretty sure that Bob Martin included Liskov Substitution purely because he needed an L or he couldn’t spell SOLID.

In the mid-’90s, Barbara Liskov wrote a research paper on the topic of types, at a mathematical level. (Fun fact: Her maiden name was “Huberman,” but if she hadn’t changed her name when she got married, DOISH just wouldn’t have worked as well as an acronym, would it?) Everybody likes to hold up the L in SOLID as evidence that SOLID “has mathematical grounding, see!?” But that couldn’t be further from the truth.

Liskov formally defined substitutability like this:

Subtype Requirement: Let ϕ(x) be a property provable about objects x of type T. Then ϕ(y) should be true for objects y of type S where S is a subtype of T.

Liskov and Wing, “A behavioral notion of subtyping,” 1994.

Mathematicians are funny creatures, and often their writing leaves out things that are obvious to the mathematicians but not to everyone else. This is one of those cases.

The normal explanation of Liskov substitution goes something like this: “If you have a base type T, and a subtype S that inherits from T, and T has a property or field or method A, then S must have a property or field or method A too.”

But that’s not what Liskov and Wing meant at all. They didn’t mean data and methods. They meant provable propertiesanything that you can prove about the parent type, you must be able to establish about the child as well for it to be considered a “subtype” of the parent.

That includes side effects. Anything you can observe about an object is a provable property.

Under proper use of Liskov’s substitution principle, LoggerBase cannot be substituted with ConsoleLogger or FileLogger or DatabaseLogger because each of those classes have different provable properties. The expected output is a provable property, and that’s different between the parent and each of its children — the parent had none!

Using the terms properly, as they were originally defined, they don’t have much value for modern software. And really, as used in SOLID, the fancy name “Liskov Substitution” really is just people saying “you should allow inheritance,” which is just another way of restating the O of SOLID, and which we already established is a bad idea.

It’s telling that in recent years, Liskov and Wing have distanced themselves both from the original paper and from the concept entirely, as expressed in this great quote from Wikipedia:

In an interview in 2016, Liskov herself explains that what she presented in her keynote address was an “informal rule”, that Jeannette Wing later proposed that they “try to figure out precisely what this means”, which led to their joint publication on behavioral subtyping, and indeed that “technically, it’s called behavioral subtyping”. During the interview, she does not use substitution terminology to discuss the concepts.

Wikipedia, Liskov Substitution Principle

Barbara Liskov doesn’t use the Liskov Substitution Principle. You shouldn’t either.

I

We’re doing well here: So far, S is good but incredibly obvious; O is a terrible idea; and L‘s creator doesn’t like it. How does I hold up?

The interface-segregation principle says that no consumer of a class should be forced to depend on methods it doesn’t use. That’s great, as far as it goes…

…which isn’t very far, because if you have methods on a class that aren’t being consumed, why are they there in the first place? Odds are good you’ve violated the single-responsibility principle if you’ve gotten this far: If class X has ten methods, and each consumer A, B, and C of it only needs five of them, then X is probably too smart for its own good! X is very likely three classes, not one, and you should split it up.

If you’ve naturally divided up your code so that each piece only has a single responsibility, odds are good those pieces’ interfaces will be narrow too: A class that does one thing and does it well rarely needs tons and tons of entry points.

So while “keep your interfaces narrow” is good advice, as far as it goes, that’ll generally happen anyway if you focus hard on having each piece of your code do only one task and do it well: I is really just a restatement of S using slightly different terminology.

D

One letter to go: What about the D? Is dependency inversion a good idea?

Honestly, the jury’s still out on this one.

In theory, inverting dependency isn’t inherently evil. Rather than class A instantiating the classes B and C that it needs, you pass them into it as parameters. That can help to decouple code: A doesn’t know what objects it’s using, and B and C don’t know who’s using them. Loose coupling for the win.

But this often goes off the rails pretty fast. First off, many programmers prefer to pass either base classes or interfaces — IB and IC — rather than concrete implementations like B and C. But we already established that inheritance is evil, and base classes and interfaces (which are really the same thing) are forms of inheritance: They’re tight couplings in your code that you’ll eventually regret having. Having class A take in IB and IC rather than B and C always makes the code more complex — class B and interface IB describe the same thing twice. Each time you need to change B, you need to change IB too, and A, and anything else that implements IB, like a mock class for testing. All that indirection can cost substantial performance. And you may have to resort to complicated heroics to allow class A actually know things about the classes it’s using, when it needs to know things.

And it can get worse. Many architects like to advocate not just dependency inversion but dependency injection or service location or inversion of control using tools like Castle or Ninject or Unity or Guice or Spring, where a single shared “service bucket” is responsible for filling in every dependency in every instance.

…in other words, we worked really, really hard to reinvent global variables.

Decoupling your classes from the components they use can have some value, but don’t go overboard with it. Not everything needs an interface. And if you’re simply using global variables in disguise through a service locator full of singletons, your code isn’t any more maintainable than it would be if you had just declared the global variables for real.

(And for those of you who say, “Waah, but how do I test without DI?” or “But how do I decouple without base classes or interfaces!?” — we should definitely have longer discussions about decoupling and testing, but that would blow the scope of this essay right out of the water.)

So… S?

What are we left with? S is simple and obvious; O is a bad idea; L was disavowed by its creator; I is a restatement of S; and D is maybe-good-maybe-bad-but-definitely-easy-to-get-wrong.

So much for SOLID.

Don’t be a cargo-cult programmer. If you break SOLID apart, there’s not a lot of actual substance left in it. It’s repeated often because it’s flashy and memorable, but really, it exists to produce books and speaking engagements, not to produce clean software. If you have to learn any flashy acronym at all, learn KISS instead: It’s been around longer, it’s easier to remember, it covers everything you needed to know about S anyway, and it’s way, way more useful, not just in programming but everywhere else in life too.