All that is SOLID melts into air...
Every software developer (hopefully) strives to write code which is robust and maintainable. We’ve all worked on projects where a simple code-change broke a million seemingly unconnected features, or random bugs kept popping up in production as soon as the system was used by more than three people, or vast swathes of the codebase were deemed untouchable because nobody could understand how they worked. Over the past three decades, a number of architectural principles, design patterns, cure-alls and get-rich-quick schemes have emerged, promising a life free of production bugs and technical debt.
The most well-known of these is SOLID, a set of principles first outlined by Bob Martin in his 2000 paper Design Principles and Design Patterns. SOLID stands for Single responsibility, Open/closed, Liskov Substitution, Interface segregation and Dependency inversion.
The single responsibility principle
According to this principle, “A module should be responsible to one, and only one, actor.” Dan North somewhat acerbically describes this as the ‘pointlessly vague principle’. The ‘princliple’ tells us nothing about what size or shape a class should be in practice. Does a controller violate it? The most extreme interpretation of the single responsibility principle leads to single action controllers, which are structured as one might expect: imagine working on an an even moderately-complex CRUD feature built this way, flitting between half a dozen tiny controller classes for a single user workflow.
But is this the ‘correct’ interpretation of SRP? Perhaps a ‘responsiility’ should be defined more broadly, such as a group of CRUD actions for an entity. Or perhaps its ‘responsibility’ is managing CRUD on a single user screen, which could include multiple entities?
All of this discussion of ‘correct interpretations’ reminds one of Talmudic scholars debating the true meaning of boiling a kid in its mother’s milk: religious arguments about a vague text leading to nothing useful. (With apologies to my Kosher family members.) In fairness, Martin does try to clarify: “A class should have only one reason to change”. What reasons might we want to change a controller class? Perhaps we want to introduce logging. Perhaps we’re adding data validation. Perhaps we’re fixing a bug, or adding a new API response code. Even with single action controllers, that’s three reasons right there, and that’s before we get onto fundamental changes to the business logic.
Which brings us onto…
The open/closed principle
According to this principle, “A class should be open for extension, closed for modification.” In other words, it’s ok to add new behaviours to a class, but existing behaviours should remain untouched. To add some historical context, Bob Martin cut his teeth on writing device drivers for embedded systems in C in the 1970s, before moving onto Xerox machines in the 90s. The idea of not modifying existing functionality makes perfect sense for device drivers: they’re running on physical hardware which cannot be easily modified, they need to be backwardly-compatible, etc. However, useful guidance in one domain doesn’t necessarily translate to another, such as web development. If, for example you’re building an internal API for your React app, you can (and should) refactor as you go, otherwise you get what North describes as the cruft accretion principle. In practice, the worst codebases are full of code which hasn’t been refactored enough: it’s been extended repeatedly without the necessary clean-up.
Liskov substitution
The Liskov substitution principle is the first SOLID principle whic actually has a precise, mathematical definition. According to the principle, an instance of a class may be replaced by an instance of a subclass without breaking the application. Whilst the principle is a good description of how to write inheritance hierarchies, it tells us nothing about when to write them. Inheritance is vastly over-used, especially in enterprise software. In many cases, we should prefer composition over inheritance. Understanding when to use inheritance is just as important as how to do it correctly.
Interface segregation
According to this principle, “Clients should not be forced to depend upon interfaces that they do not use.” Bob Martin developed this principle during his work with Xerox, to help him refactor a ‘god class’ called Job: he grouped methods together inside this class, defined interfaces for each group of methods, then replaced references to Job with references to these smaller interfaces. This made he calling-code much easier to understand, since it was referencing smaller, more coherent interfaces.
As a strategy for refactoring, it can be useful in certain contexts. However, mandating that coherent classes be broken up into interfaces with a single implementation adds unnessary complexity. A File class which handles disk CRUD operations is absolutely fine, even if not every use-case involves every possible operation: adding IReadable and IWritable interfaces, and mandating their usage across the codebase, achieves little other than to increase the cognitive load of the developers, or force a refactor if the code is extended to implement other CRUD operations. Much like Liskov substitution, interface segregation is not wrong, but it’s not a principle. It provides useful advice in certain contexts, but is not universally applicable.
Dependency inversion
The dependency inversion principle states that modules should depend upon abstractions, not concretes. In OOP, this means defining interfaces for each dependency, and passing these interfaces to the depending class, rather than the concrete types. In practice, this usually means leveraging an inversion of control (IoC) framework such as Autofac.
All of this leads to a plague of single-use interfaces, breaks context-navigation in IDEs, and generally makes it harder to reason about what the code is actually doing. You control (or command)-click on a method, and are taken to a tiny interface instead of the implementation you’re looking for. Unless the implementation really is context-dependent, then this extra complexity serves no purpose whatsoever.
This doesn’t mean IoC frameworks are completely useless: there are definitely use-cases where they simplify the code. But again, they’re far from universally applicable.
Summary
The so-called principles of SOLID are in actuality a mixture of context-dependent advice (interface segregation, DI), computer science theory with little practical relevance to most developers (Liskov substitution principle) and vague formulations which often lead to bad code in practice (SRP, open/closed). More fundamentally, the problems with SOLID stem from what it’s trying to do: be a universal set of rules for writing very different types of software. I personally believe this is impossible. With apologies to Epimenides of Crete, the only principle I can advocate is a healthy disregard for these kinds of principles! There’s no substitute for intelligence, experience and humility.
That being said, there are definitely good practices, and solid (pun intended) advice that can help us all become better developers. In the next post I’ll share some thoughts that I’ve formed over my two decades of writing software.