Loading…

Why SOLID principles are still the foundation for modern software architecture

While computing has changed a lot in the 20 years since the SOLID principles were conceived, they are still the best practices for designing software.

Article hero image

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 asubtype 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!

Login with your stackoverflow.com account to take part in the discussion.