Being in the industry for over 7 years, I’ve had the chance to work with companies from early-stage startups to Fortune 500. They’re very different in terms of the work organization. Yet, surprisingly similar in one area: lowering expenses at the cost of proper software architecture.
Product owners don’t always understand the implications of bad software design. It's on us, as engineers, to consider the best design practices when estimating and make sure we write code that’s easy to maintain and extend. SOLID design principles can help us achieve that.
What are SOLID Design Principles?
SOLID is an acronym formed by the names of 5 design principles centered around better code design, maintainability, and extendability. The principles were first introduced by Robert Martin (more familiar in the developer circles as Uncle Bob) in his 2000 paper Design Principles and Design Patterns. The principles were later named by Michael Feathers who switched their order so they can form the SOLID acronym.
The SOLID design principles will guide you to:
- write code that’s easy to maintain;
- make it easier to extend the system with new functionality without breaking the existing ones;
- write code that’s easy to read and understand.
In simple terms, the SOLID design principles act like UX principles for developers. Have you ever worked on a bug where you had to spend hours reading the code in order to understand what to do? Great developer experience means you can easily navigate through the code and understand what it does. Ultimately, it results in spending less time figuring out what the code does and more time actually developing the solution.
So, let’s dig into the 5 principles for object oriented design that can help us become better developers.
Single Responsibility Principle
Single Responsibility Principle is the S in SOLID. Undoubtedly the most popular one that many people argue is the first you should start with.
Single responsibility means that your class (any entity for that matter, including a method in a class, or a function in structured programming) should only do one thing. If your class is responsible for getting users’ data from the database, it shouldn’t care about displaying the data as well. Those are different responsibilities and should be handled separately.
Uncle Bob compares the Single Responsibility Principle to the concept of a clean and organized room. Having your clothes thrown around represents spaghetti code. It’s hard to go back to a messy room a year later and find what you need, right? You need things sorted out. There’s a place for everything in your room and everything should be in its place. And, everything should have its place in your code.
How to make sure your code follows the Single Responsibility Principle?
Write small classes with very specific names as opposed to large classes with generic names. For example, instead of throwing everything inside an
Employee class, separate the responsibilities into
EmployeeDetails, etc. Now you have a designated place for everything and you know exactly where to look when you get back to your code a year later.
Open/Closed Principle is the most confusing one of the 5 principles when you judge by the name. How can something be open and closed at the same time? However, it has a fairly simple idea behind it and it’s the one that can save you the most development time if you apply it correctly.
The Open/Closed Principle states that a module should be open for extension, but closed for modification. That means you should be able to extend a module with new features not by changing its source code, but by adding new code instead. The goal is to keep working, tested code intact, so over time, it becomes bug resistant.
Applying the Open/Closed Principle is like working with an open source library. When you install an open source package in your project, you don’t edit its code. Instead, you depend on its behavior but you write your code around it. At one point, the library gets tested so much that it becomes very stable. This is the approach you should take with your own code: add new features as new pieces of code that cannot break what’s already working.
How to make sure your code follows the Open/Closed Principle?
Let’s say you need to implement an e-commerce module with multiple payment options. You can create a class
Pay and add different payment options as separate methods. This would work, but it would result in changing the class whenever we want to add or change a payment option.
Instead, try creating a
PayableInterface that will guide the implementation of each payment option. Then, for every payment option, you will have a separate class that implements that interface. Now the core of your system is closed for modification (you don’t need to change it every time you add new payment option), but open for extension (you can add new payment options by adding new classes).
Liskov Substitution Principle
The Liskov Substitution Principle was first introduced by Barbara Liskov (an American computer scientist) in the late 80s. The mathematical formulation goes like this:
“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.”
Or, in software engineering terms, you should be able to substitute a class with any of its subclasses, without breaking the system. Putting it more simply, implementations of the same interface should never give a different result.
Imagine this scenario: you’re working on a new app and instead of creating your database structure from the beginning you use a file system for testing purposes. Now, you have some repository that communicates with that file system - reads through the files and prepares them, so it can return the data to your program in an array format. After completing the development process, you decide it’s time to replace the file system with a database. You change your repository to implement all the same methods for working with the database. But, the system breaks, because your database methods now return objects instead of arrays.
How to make sure your code follows the Liskov Substitution Principle?
If your programming language supports type-hinting for return types in interface methods, you can use that to avoid the issue of having a different return type in different implementations. Still, it doesn’t solve everything. Throwing an exception in the middle of a method implementation where it’s not expected is also a violation of the Liskov Substitution Principle. So, the best way to make sure you follow this principle is mindful programming. Always keep in mind what the system expects when you’re implementing its functionality.
Interface Segregation Principle
The Interface Segregation Principle states that you should never force the client to depend on methods it doesn’t use. Let’s look back to the Single Responsibility Principle example for this one.
Imagine you have a large Employee class with all the methods you need for managing employees in your system. Then, you have multiple controllers designed in RESTful fashion, one for each functionality you need on your website (e.g.
EmployeeDetailsController, etc.). Now, all these controllers depend on the
Employee class for handling the respective functionality. Changing something in the
Employee class can break all of these controllers because they all depend on it.
How to make sure your code follows the Interface Segregation Principle?
Employee class into smaller classes with specific interfaces. Once you do that, adjust the controllers so they all depend only on the interfaces they need. This way, you’ll have a clean structure where the client depends only on the methods it uses. Changing something in a certain class will only affect the parts of the system that actually depend on it.
Dependency Inversion Principle
The dependency inversion principle states that high-level modules should not depend on low-level modules - both should depend on abstractions. Let's use an example to explain this. Looking into your house, you’re using electricity to plug in your laptop, your phone, lamp, etc. They all have a unified interface to get electricity: the socket. The beauty of it is that you don’t need to care about the way the electricity is provided. You simply rely on the fact that you can use it when needed.
Now, imagine if instead of depending on the socket as an interface, you had to wire things up every time you needed to charge your phone. In software terms, that’s what we do whenever we depend on concrete implementations: we know too much about how things are implemented which makes it easy to break the system.
How to make sure your code follows the Dependency Inversion Principle?
By combining the dependency injection technique with the concept of binding an interface to a concrete implementation, you can make sure you never depend on concrete classes. This will allow you to easily change the implementation of specific parts of the system without breaking it. A good example of this is switching your database driver from SQL to NoSQL. If you depend on the abstract interfaces for accessing the database, you’d be able to easily change the specific implementations and make sure the system works properly.
The SOLID design principles are meant to be a guide for designing software that’s easy to maintain, extend and understand. If used properly, they will help your team spend less time understanding what the code does and more time building cool features. Which is all we dream of, right?