Why SOLID principles are still the foundation for modern software architecture
The SOLID principles are a time-tested rubric for creating quality software. But in a world of multi-paradigm programming and cloud computing, do they still stack up? I’m going to explore what SOLID stands for (literally and figuratively), explain why it still makes sense, and share some examples of how it can be adapted for modern computing.
What is SOLID?
SOLID is a set of principles distilled from the writings of Robert C. Martin in the early 2000s. It was proposed as a way to think specifically about the quality of object-oriented (OO) programming. As a whole, the SOLID principles make arguments for how code should be split up, which parts should be internal or exposed, and how code should use other code. I’ll dive into each letter below and explain its original meaning, as well as an expanded meaning that can apply outside of OO programming.
What has changed?
In the early 2000’s, Java and C++ were king. Certainly in my university classes, Java was our language of choice and most of our exercises and lessons used it. The popularity of Java spawned a cottage industry of books, conferences, courses, and other material to get people from writing code to writing good code.
Since then, the changes in the software industry have been profound. A few notable ones:
- Dynamically-typed languages such as Python, Ruby, and especially JavaScript have become just as popular as Java—overtaking it in some industries and types of companies.
- Non-object-oriented paradigms, most notably functional programming (FP), are also more common in these new languages. Even Java itself introduced lambdas! Techniques such as metaprogramming (adding and changing methods and features of objects) have gained popularity as well. There are also “softer” OO flavors such as Go, which has static typing but not inheritance. All this means that classes and inheritance are less important in modern software than in the past.
- Open-source software has proliferated. Whereas earlier, the most common practice would be to write closed-source compiled software to be used by customers, nowadays it’s much more common for your dependencies to be open-source. Because of this, the kind of logic and data hiding that used to be imperative when writing a library is no longer as important.
- Microservices and software as a service exploded onto the scene. Rather than deploying an application as a big executable that links all its dependencies together, it’s much more common to deploy a small service that talks to other services, either your own or powered by a third party.
Taken as a whole, many of the things that SOLID really cared about—such as classes and interfaces, data hiding, and polymorphism—are no longer things that programmers deal with every day.
What hasn’t changed?
The industry is different in many ways now, but there are some things that haven’t changed and likely won’t. These include:
- Code is written and modified by people. Code is written once and read many, many times. There will always be a need for well-documented code, particularly well-documented APIs, whether internal or external.
- Code is organized into modules. In some languages, these are classes. In others, they may be individual source files. In JavaScript, they may be exported objects. Regardless, there exists some way of separating and organizing code into distinct, bounded units. Therefore, there will always be a need to decide how best to group code together.
- Code can be internal or external. Some code is written to be used by yourself or your team. Other code is written to be used by other teams or even by external customers (through an API). This means there needs to be some way to decide what code is “visible” and what is “hidden.”
“Modern” SOLID
In the following sections, I will restate each of the five SOLID principles to a more general statement that can apply to OO, FP, or multi-paradigm programming and provide examples. In many cases, these principles can even apply to whole services or systems!
Note that I will use the word module in the following paragraphs to refer to a grouping of code. This could be a class, a module, a file, etc.
Single responsibility principle
Original definition: “There should never be more than one reason for a class to change.”
If you write a class with many concerns, or “reasons to change”, then you need to change the same code whenever any of those concerns has to change. This increases the likelihood that a change to one feature will accidentally break a different feature.
As an example, here’s a franken-class that should never make it to production:
class Frankenclass {
public void saveUserDetails(User user) {
//...
}
public void performOrder(Order order) {
//...
}
public void shipItem(Item item, String address) {
// ...
}
}
New definition: “Each module should do one thing and do it well.”
This principle is closely related to the topic of high cohesion. Essentially, your code should not mix multiple roles or purposes together.
Here’s an FP version of this same example using JavaScript:
const saveUserDetails = (user) => { ... }
const performOrder = (order) => { ...}
const shipItem = (item, address) => { ... }
export { saveUserDetails, performOrder, shipItem };
// calling code
import { saveUserDetails, performOrder, shipItem } from "allActions";
This could also apply in microservice design; if you have a single service that handles all three of these functions, it’s trying to do too much.
Open-closed principle
Original definition: “Software entities should be open for extension, but closed for modification.”
This is part of the design of languages like Java—you can create classes and extend them (by creating a subclass), but you can’t modify the original class.
One reason for making things “open for extension” is to limit the dependency on the author of the class—if you need a change to the class, you’d constantly need to ask the original author to change it for you, or you’d need to dive into it to change it yourself. What’s more, the class would start to incorporate many different concerns, which breaks the single responsibility principle.
The reason for closing classes for modification is that we may not trust any and all downstream consumers to understand all the “private” code we use to get our feature working, and we want to protect it from unskilled hands.
class Notifier {
public void notify(String message) {
// send an e-mail
}
}
class LoggingNotifier extends Notifier {
public void notify(String message) {
super.notify(message); // keep parent behavior
// also log the message
}
}
New definition: “You should be able to use and add to a module without rewriting it.”
This comes for free in OO-land. In an FP world, your code has to define explicit “hook points” to allow modification. Here’s an example where not only before and after hooks are allowed, but even the base behavior can be overridden by passing a function to your function:
// library code
const saveRecord = (record, save, beforeSave, afterSave) => {
const defaultSave = (record) => {
// default save functionality
}
if (beforeSave) beforeSave(record);
if (save) {
save(record);
}
else {
defaultSave(record);
}
if (afterSave) afterSave(record);
}
// calling code
const customSave = (record) => { ... }
saveRecord(myRecord, customSave);
Liskov substitution principle
Original definition: “If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program.”
This is a basic attribute of OO languages. It means that you should be able to use any subclass in place of their parent class. This allows for confidence in your contract—you can safely depend on any object that “is a” type T
to continue to behave like a T
. Here it is in practice:
class Vehicle {
public int getNumberOfWheels() {
return 4;
}
}
class Bicycle extends Vehicle {
public int getNumberOfWheels() {
return 2;
}
}
// calling code
public static int COST_PER_TIRE = 50;
public int tireCost(Vehicle vehicle) {
return COST_PER_TIRE * vehicle.getNumberOfWheels();
}
Bicycle bicycle = new Bicycle();
System.out.println(tireCost(bicycle)); // 100
New definition: You should be able to substitute one thing for another if those things are declared to behave the same way.
In dynamic languages, the important thing to take from this is that if your program “promises” to do something (such as implement an interface or a function), you need to keep to your promise and not surprise your clients.
Many dynamic languages use duck typing to accomplish this. Essentially, your function either formally or informally declares that it expects its input to behave a particular way and proceeds on that assumption.
Here’s an example using Ruby:
# @param input [#to_s]
def split_lines(input)
input.to_s.split("\n")
end
In this case, the function doesn’t care what type input
is—only that it has a to_s
function that behaves the way all to_s
functions are supposed to behave, i.e. it turns the input into a string. Many dynamic languages don’t have a way to force this behavior, so this becomes more of a discipline issue than a formalized technique.
Here’s an FP example using TypeScript. In this case, a higher-order function takes in a filter function which expects a single numeric input and returns a boolean value:
const isEven = (x: number) : boolean => x % 2 == 0;
const isOdd = (x: number) : boolean => x % 2 == 1;
const printFiltered = (arr: number[], filterFunc: (int) => boolean) => {
arr.forEach((item) => {
if (filterFunc(item)) {
console.log(item);
}
})
}
const array = [1, 2, 3, 4, 5, 6];
printFiltered(array, isEven);
printFiltered(array, isOdd);
Interface segregation principle
Original Definition: “Many client-specific interfaces are better than one general-purpose interface.”
In OO, you can think of this as providing a “view” into your class. Rather than giving your full implementation to all your clients, you create interfaces on top of them with just the methods relevant to that client, and ask your clients to use those interfaces.
As with the single responsibility principle, this decreases coupling between systems, and ensures that a client doesn’t need to know about, or depend on, features that it has no intention of using.
Here’s an example that passes the SRP test:
class PrintRequest {
public void createRequest() {}
public void deleteRequest() {}
public void workOnRequest() {}
}
This code will generally have only one “reason to change”—it’s all related to print requests, which are all part of the same domain, and all three methods will likely change the same state. However, it’s not likely that the same client that’s creating requests is the one that’s working on requests. It makes more sense to separate these interfaces out:
interface PrintRequestModifier {
public void createRequest();
public void deleteRequest();
}
interface PrintRequestWorker {
public void workOnRequest()
}
class PrintRequest implements PrintRequestModifier, PrintRequestWorker {
public void createRequest() {}
public void deleteRequest() {}
public void workOnRequest() {}
}
New Definition: “Don’t show your clients more than they need to see”.
Only document what your client needs to know. This might mean using documentation generators to only output “public” functions or routes and leave “private” ones un-emitted.
In the microservice world, you can either use documentation or true separation to enforce clarity. For example, your external customers may only be able to log in as a user, but your internal services might need to get lists of users or additional attributes. You could either create a separate “external only” user service that calls your main service, or you could output specific documentation just for external users that hides the internal routes.
Dependency inversion principle
Original definition: “Depend upon abstractions, not concretions.”
In OO, this means that clients should depend on interfaces rather than concrete classes as much as possible. This ensures that code is relying on the smallest possible surface area—in fact, it doesn’t depend on code at all, just a contract defining how that code should behave. As with other principles, this reduces the risk of a breakage in one place causing breakages elsewhere accidentally. Here’s a simplified example:
interface Logger {
public void write(String message);
}
class FileLogger implements Logger {
public void write(String message) {
// write to file
}
}
class StandardOutLogger implements Logger {
public void write(String message) {
// write to standard out
}
}
public void doStuff(Logger logger) {
// do stuff
logger.write("some message")
}
If you’re writing code that needs a logger, you don’t want to limit yourself to writing to files, because you don’t care. You just call the write
method and let the concrete class sort it out.
New definition: “Depend upon abstractions, not concretions.”
Yep, in this case I’d leave the definition as is! The idea of keeping things abstract where possible is still an important one, even if the mechanism of abstracting in modern code is not as strong as it is in a strict OO world.
Practically, this is almost identical to the Liskov substitution principle discussed above. The main difference is that here, there is no default implementation. Because of this, the discussion involving duck typing and hook functions in that section equally applies to dependency inversion.
You can also apply abstraction to the microservice world. For example, you can replace direct communication between services with a message bus or queue platform such as Kafka or RabbitMQ. Doing this allows the services to send messages to a single generic place, without caring which specific service will pick those messages up and perform its task.
Conclusion
To restate “modern SOLID” one more time:
- Don’t surprise the people who read your code.
- Don’t surprise the people who use your code.
- Don’t overwhelm the people who read your code.
- Use sane boundaries for your code.
- Use the right level of coupling—keep things together that belong together, and keep them apart if they belong apart.
Good code is good code—that’s not going to change, and SOLID is a, well, solid basis to practice that!
Tags: software architecture, software engineering
30 Comments
> The reason for closing classes for modification is that we may not trust any and all downstream consumers to understand all the “private” code we use to get our feature working, and we want to protect it from unskilled hands.
A stronger statement of the reason is that we want to eliminate dependencies on downstream changes. I don’t care how trustworthy or skilled another programmer is; if they modify the behavior of a class that I wrote, it’s hard for me to change that class without breaking their code.
Agreed – much stronger point than I made! 😀
The short and sweet of this is summarized as ” Trust but verify”.
What I understand from your text is that SOLID is not serving the purpose well anymore and it needs updating.
Instead of trying to keep an ill analogy alive we should let it take it’s place in history and move on.
SOLID is outdated and not able to guide well. Let’s find something better.
Great article! Congrats!
Generally, I aim SOLID, but specifically, sometimes this is enough and right: https://speakerdeck.com/tastapod/why-every-element-of-solid-is-wrong (source: Dan North).
SOLID can sometimes lead to an over-abundance of smaller components that make it harder for developers to grasp because their combination and usage explodes to the point where they don’t easily fit into your head. There may be many types in many files and the relationships might not be immediately clear when first read (and as you state, code is read more than it is written).
It’s interesting though: your conclusion matches SOLID’s goals, *and* matches the assertions in my reference! How can these two co-exist? Simple: they both have their place.
I really like that deck! I think I was attempting to “update” SOLID to more closely approximate what Dan was saying there so we kind of came at the same thing from different sides.
Whether or not all SOLID principles are good is debatable. The two major issues are that they are ivory tower principles and their spirit mostly misunderstood, more often than not resulting in code nearly incomprehensible to an outsider and nearly impossible to maintain, debug and extend. IMHO the first and most fundamental aspects of good code are clarity, documentation, reusability and extendability. Code that fundamentally meets those requirements does not necessarily conform to all SOLID principles.
I don’t disagree, and generally when I do code reviews or try to train people on “good” code I will rarely refer to SOLID. I was mainly trying to demonstrate how these principles, when understood the way I understood them, still closely approximate “good” code, whether they’re directly referred to or not.
I think your redefinition of the Open-Closed principle is wrong. When Meyer wrote “software entities”, he was referring to their interfaces not to their implementations. It’s totally fine to rewrite a module, or even having to rewrite it when adding functionality to it. What must not break is the interface.
“Closed for modification” means “You should not change your module (interface) so that you have to rewrite its usages”, and “open for extension” means “You should be able to add functionality to your modules and use that in new code without breaking existing usages”. This could for example be adding new methods to an interface, or adding optional parameters to existing methods. It is especially relevant for libraries or communication protocols where some entities might still be using the old version.
It’s very possible I got the wrong idea for this. There were actually a few rewrites as I tried to get a better idea of how these principles are understood. Part of the issue with SOLID is the confusion people feel around what *exactly* each of them mean – but that’s also a benefit, in that the flexibility of multiple interpretations let me reuse the concepts I got from them into things I hope are useful across the board.
Nice article.
Have you compared SOLID with IDEALS? The latter seems the more modern one. https://dev.to/dominguezdaniel/principles-for-microservice-design-ideals-rather-than-solid-3k0g
I haven’t seen that one before! It seems to be specifically about designing services rather than something more general that can be on a language level. I’m not sure that “event-driven” would be an ideal that all microservices should have, as there are plenty of cases where it causes more problems than it solves. But overall it seems pretty reasonable.
SOLID is “too much” theorical and ignores the Real World with budgets, deadlines and much more importantly, the risk of the unknown unknowns.
In real scenarios developers must take risks, LOT of RISKS.
Developers must create fast dirty code that” just work!” to adapt to not well defined business scenarios. We all would like to work against a set of well defined, othorgonal, functional, atomic, idempotent interfaces with no side effects. In the real world we must create “business services” to adapt to monetization patterns that move in the wild. Today it’s linked to adverticement patterns, tomorrow to pay-per-view, next day to crypto-NFTs, and in a couple of weeks a new regulatory requirement requires to patch random pieces of codes to avoid being fined or change from a relational API to a graph API, to an stream API with input data injected from manual forms or IA controlled streams…
Of course, there are “zone of peace” were calm is the norm and SOLID principles apply.. But again laws of economy dictate that those are less and less important. Once an stable procedure or software is in place, there is no much money to do except for the “first-to-market” winners. Then most developers must fight to work on new business scenarios with no clear inputs, completely unknown outputs. And that is why SOLID will not work. It departs from the assumption that World is rational and projects are rational … Just read Nicholas Taleb’s “Black Swarm” to convince the opposite is true. Outside the University Universe, chaos is the norm, not the exception. No interfaces or hierarchies types really exists (or they do .. for a very short time).
Scripting languages adoption is the result of being “good enough” to write fast dirty code and get rid of theory and complexity (no threads, no optimization tricks, well defined dom models, simple -http- networking,…). Facebook was originally written in PHP and it’s being said that code was quite poor… But to Facebook user’s engagement is everything. Code is secundary.
Something similar is happening now with the cloud. From a business perspective, creating vendor’s lock-in into (aws, azure, gcp,…) private clouds and getting sure of bigger and bigger sources of income is all that matters. No body is interested in applying SOLID, good quality code working on a Raspberry Pi. In the business world, ” The worst is the best!”
nice one. I’m surprised how much code I continously have to deal with, that absolutely nothing has to do with SOLID or just “clean code”. The world seems to be full of legacy code and monolithic architectures. Companies spend a lot of money to software modernization. I’m probably a SOLID fundamentalist in terms of: the goal is to write clear & understandable pieces of code, that may be tested and continuously composed to something new(aka reused), so for me, there’s nothing wrong to try to follow SOLID principles all the day.
I don‘t see the sense of the saveRecord example as the calling program doesn‘t need the library code at all. Calling customSave(myRecord) would be enough.
The example snipped out a bunch of stuff in between the hooks – I could have made that more obvious or thought of a better way to demonstrate that.
Nice article! I enjoyed reading it. I just wanted to mention that while SOLID was coined 20 odd years ago, good developers were practicing the principals even if we didn’t have a cute acronym to summarize them.
No, they are not!
Good article. I agree that SOLID principles are important to understand, however, I’ve seen them morph into religion on several projects. Specifically, I’ve been hired several times to fix OO spaghetti. Usually, the culprit is a combination of too much inheritance, too much dependency injection, and too much abstraction generally (ORM specifically destroyed a couple of projects). Also, while the Gof4 patterns are important to understand, software development has innovated way past those. Most of the time, solutions can be implemented in much simpler ways. Part of the reason for that, of course, is that languages have become increasingly powerful and feature-rich. Usually, when I walk into a place to fix what they have, it involves ripping out much of the dogmatic and bloated OO code and drastically simplifying the code base with a combination of OO lite, functional programming, and in some cases, a procedural paradigm.
No arguments here! As with everything in software engineering, there’s no such thing as a rule that should never be broken.
Interesting perspective. But I was taken aback that your “new” definition of SRP contains as its crux the phrase “do one thing and do it well”, without any reference to the “Unix philosophy” (https://en.wikipedia.org/wiki/Unix_philosophy), which is at least 20 years older than SOLID. What is old is new again?
There also a handy and very simple way to modify buissness code. So called Feature Toggle. It sould be added to SOLID.
if( feature_is_active(‘myfeature’) ){
new code
}else{
old code
}
This approach allows switching between old and new versions of your software and do a/b testing.
After while you can delete if() and leave only one code you want.
SOLID doesn’t mean much to me, it is just a blockade in the way of getting things done.
It is a set of guidelines that is NOT about programming, are they bad per se? , probably not, but not always as useful as someone may think.
One can make examples fit the principles, but I remain to be convinced.
Here’s an article challenging the usefulness of the SOLID principles.
https://www.linkedin.com/pulse/how-solid-coding-principles-graham-berrisford
If you think that article misguided, please comment on it.
Thanks for the article!
I tried the snippet for the subsistution principle and had troubles getting it to compile.
I’m very new to Java but thought I’d share my thoughts here and provide the source
for the resolution I found: https://stackoverflow.com/questions/2559527/non-static-variable-cannot-be-referenced-from-a-static-context
The post says SOLID principles are “to protect the code from unskilled hands”, which is far from the original purpose if you read the papers from Uncle Bob. Also, the first code example merely converts a class style of writing to another class style of writing using export { methodA, methodB }, it helps nothing to understand better SRP; in fact, there’s no Single Responsibillity separation there at all, as responsibility is defined by a set of cohesive operations that change together, and the examples only shows a coding style. By the way the post was written, it seems like the author/authors have no idea of what SOLID really is.
Thanks for sharing! I have one comment on the DI principle – specifically, on this part: “𝘠𝘰𝘶 𝘤𝘢𝘯 𝘢𝘭𝘴𝘰 𝘢𝘱𝘱𝘭𝘺 𝘢𝘣𝘴𝘵𝘳𝘢𝘤𝘵𝘪𝘰𝘯 𝘵𝘰 𝘵𝘩𝘦 𝘮𝘪𝘤𝘳𝘰𝘴𝘦𝘳𝘷𝘪𝘤𝘦 𝘸𝘰𝘳𝘭𝘥. 𝘍𝘰𝘳 𝘦𝘹𝘢𝘮𝘱𝘭𝘦, 𝘺𝘰𝘶 𝘤𝘢𝘯 𝘳𝘦𝘱𝘭𝘢𝘤𝘦 𝘥𝘪𝘳𝘦𝘤𝘵 𝘤𝘰𝘮𝘮𝘶𝘯𝘪𝘤𝘢𝘵𝘪𝘰𝘯 𝘣𝘦𝘵𝘸𝘦𝘦𝘯 𝘴𝘦𝘳𝘷𝘪𝘤𝘦𝘴 𝘸𝘪𝘵𝘩 𝘢 𝘮𝘦𝘴𝘴𝘢𝘨𝘦 𝘣𝘶𝘴 𝘰𝘳 𝘲𝘶𝘦𝘶𝘦 𝘱𝘭𝘢𝘵𝘧𝘰𝘳𝘮 𝘴𝘶𝘤𝘩 𝘢𝘴 𝘒𝘢𝘧𝘬𝘢 𝘰𝘳 𝘙𝘢𝘣𝘣𝘪𝘵𝘔𝘘.”
I would argue that when you integrate with a (let’s say) RESTful API directly, you’re already depending on an abstraction, as the microservices underneath can be replaced anytime without impacting the clients – providing that the API contract doesn’t change. In my mind, this is a direct analogy to using an interface (rather than a concrete class implementation). And if there’s an API gateway on top, the abstraction becomes even easier to work with.
Hey Daniel. In the TypeScript version of LSP, I cannot see any Parent/Child substitution. The both `isEven` and `isOdd` functions are in the same level of hierarchy and have no parents. Can you please clarify this for me? Thanks.