In software development, writing clean, robust code is vital. Yet, managing complex projects with multiple developers and evolving requirements can pose challenges to codebase organization and comprehension. That’s where SOLID principles step in. SOLID, an acronym for five core design principles, offers a proven framework that boosts code quality, scalability, and collaboration. Whether you’re a seasoned pro or just starting out, understanding and implementing SOLID principles can transform your coding abilities.
When software development teams follow SOLID principles, they can create systems that:
- work well, are easy to maintain, and can grow as needed
- provide users with a better experience, work reliably, are efficient
- have good overall code quality
What are the SOLID principles?
The SOLID principles are a set of rules that help make your code clean, easy to maintain, and flexible. The principles are:
- S – Single Responsibility Principle (SRP)
- O – Open Closed Principle (OCP)
- L – Liskov Substitution Principle (LSP)
- I – Interface Segregation Principle (ISP)
- D – Dependency Inversion Principle (DIP)
In this blog post, we’ll dive into the world of SOLID principles, exploring their core concepts, benefits, and practical tips to help you write better code and unlock the true potential of your software projects.
A brief history of SOLID principles
Robert C. Martin introduced the SOLID principles in his 2000 essay titled “Design Principles and Design Patterns”. Martin is a prominent software development advocate. He emphasizes relying on abstractions (interfaces or base classes) rather than concrete implementations for flexibility, maintenance, and testability.
Single Responsibility Principle
SRP encourages maintainability
SRP states that a class should have only one responsibility and should not be responsible for multiple things. The division of responsibilities helps you:
- maintain the code more effectively and efficiently
- reduces number of bugs in code because testing is much easier
Example
For example, when a class has multiple responsibilities, it can become complex and difficult to maintain.
Let’s say there’s a class that has to do two things:
- get data
- work with that data
It’s tricky to make changes to one of those things without accidentally affecting the other. SRP helps make this easier by encouraging you to separate these responsibilities into separate classes. This way it’s easier to make changes without affecting other parts of the code.
Before
In the code below, we created the Employee class with multiple responsibilities:
- saving employee information to the database
- calculating employee pay
- ending emails to employees
This violates SRP, which states that a class should only have one reason to change.
namespace EmployeeManagement {
public class Employee{
public int Id {get; set;}
public string Name {get; set;}
public string Email {get; set;}
public DateTime DateOfBirth {get; set;}
public decimal Salary {get; set;}
publick void Save(){
// Code to save employee information to the database
}
public void CalculatePay(){
// Code to calculate employee pay
}
public void SendEmail(){
// Code to send email to employee
}
}
}
After
In the code below, we separated each responsibility into its own class:
- the
Employee
class only contains data and has no methods - the
EmployeeRepository
class saves employee information to the database - the
PayrollService
class calculates employee pay - the
EmailService
class sends emails to employees
We made our code simpler by giving each class its own special job.
using EmployeeManagement;
namespace EmployeeManagementRepositories {
public class EmployeeRepository {
public void Save(Employee employee) {
// Code to save employee information to the database
}
}
}
using EmployeeManagement;
namespace EmployeeManagementServices {
public class PayrollService {
public void CalculatePay(Employee employee) {
// Code to calculate employee pay
}
}
}
using EmployeeManagement;
namespace EmployeeManagementServices {
public class EmailService {
public void SendEmail(Employee employee) {
// Code to send email to employee
}
}
}
Open Closed Principle
OCP encourages extensibility
OCP encourages you to design systems that accommodate new requirements without changing the existing codebase. By designing code that is open for extension but closed for changes, you can:
- introduce new functionality without the need to modify existing code
- reduce the risk of introducing bugs
- maintain the stability of the system
The key to implementing the OCP is to use abstraction and encapsulation design guidelines. This means:
- hide implementation details behind abstract interfaces
- add new functionality and extend the system without modifying the existing code
Example
For example, consider a software system that processes orders. Initially, the system only supports processing orders for physical goods. However, in the future, the company decides to add support for digital goods.
- If the system was designed using the OCP, you can add new functionality without changing the existing code.
- If the system was not designed with the OCP in mind, it is harder to add this new functionality without making significant changes to the existing code. This could introduce a new bug or make the codebase tougher to maintain.
Before
In the example below, the code has to go through various if-else statements before stumbling onto the right order type. If you wanted to add more order types in the future, you’d need to make changes to existing code block by adding another if-else statement. This means you’ll have to continually modify the existing code module. Also, adding conditional logic impacts the overall performance, efficiency and maintainability of the code.
namespace OrderProcessor {
public class OrderProcessor {
public void ProcessOrder(Order order) {
if (order.OrderType == OrderType.Retail) {
ProcessRetailOrder(order);
}
else if (order.OrderType == OrderType.Wholesale) {
ProcessWholesaleOrder(order);
}
else if (order.OrderType == OrderType.Bulk) {
ProcessBulkOrder(order);
}
}
private void ProcessRetailOrder(Order order) {
// code to process retail orders
}
private void ProcessWholesaleOrder(Order order) {
// code to process wholesale orders
}
private void ProcessBulkOrder(Order order) {
// code to process bulk orders
}
}
}
After
With this implementation, if a new type of order is added, you can simply create a new class that implements IOrderProcessor
and pass it to the OrderProcessor
constructor, without modifying the existing code.
namespace OrderProcessor {
public interface IOrderProcessor {
void ProcessOrder(Order order);
}
public class RetailOrderProcessor : IOrderProcessor {
public void ProcessOrder(Order order) {
// code to process retail orders
}
}
public class BulkOrderProcessor : IOrderProcessor {
public void ProcessOrder(Order order) {
// code to process bulk orders
}
}
}
public class OrderProcessor {
private readonly IOrderProcessor _processor;
public OrderProcessor(IOrderProcessor processor) {
_processor = processor;
}
public void ProcessOrder(Order order) {
_processor.ProcessOrder(order);
}
}
Liskov Substitution Principle
LSP also encourages extensibility
LSP states that subclasses should be able to extend the functionality of the superclass in a way that is predictable and compatible with the rest of the system. By adhering to the Liskov Substitution Principle, you can:
- avoid creating brittle systems that break when objects are replaced or changed
- ensure objects in your code are interchangable
- create more flexible, scalable, and maintainable code
Example
LSP can be implemented by using inheritance and polymorphism.
Before
The code below violates LSP because the implementation of the ReturnSpeed method in the ToyotaCar class returns a different unit of measurement compared to the Car class.
This means that if a client is expecting the speed to be returned in miles per hour (mph) and it receives a speed in kilometers per hour (km/h), it could cause unexpected behavior or errors.
namespace Cars {
public abstract class Car {
public double _speed;
public double Speed {
get { return _speed; }
set { _speed = value; }
}
public virtual double ReturnSpeed() { retur _speed; }
}
public class FordCar : Car {
pulblic override double ReturnSpeed() {
retur base.ReturnSpeed();
}
}
public class ToyotaCar : Car {
public override double ReturnSpeed() {
return base.ReturnSpeed() * 1.609344;
}
}
}
To fix this violation, you can create a separate class to handle the unit of measurement conversion and use that class in the implementation of the ReturnSpeed
method in the ToyotaCar
class.AfterIn the code below, we created an interface IVehicle that contains the properties and methods that all vehicles should have. Both FordCar
and ToyotaCar
implement this interface, and each implements the ReturnSpeed
method in a way that is specific to the type of vehicle. Now, the method ReturnSpeed in the ToyotaCar
class returns the speed in km/h, while the method in the FordCar
class returns the speed in mph. This is now in line with the LSP, as both subclasses are substitutable for the parent class and can be used interchangeably without causing any issues.
namespace Cars {
public interface IVehicle {
double Speed { get; set; }
double ReturnSpeed();
}
public class FordCar : IVehicle {
private double _speed;
public double Speed {
get { return _speed; }
set { _speed = value; }
}
public virtual double ReturnSpeed() {
return _speed;
}
}
public class ToyotaCar : IVehicle {
public double _speed;
public double Speed {
get { return _speed; }
set { _speed = value; }
}
public virtual double ReturnSpeed() {
return _speed * 1.609344;
}
}
}
Interface Segregation Principle
ISP encourages the creation of small, focused interfaces that are tailored to the specific needs of individual classes. By following the ISP, you can:
- ensure your code is more flexible, maintainable, and easier to understand
- your code updates have minimal impact on other parts of the system
ISP wants you to avoid monolithic interfaces. For example, monolithic interfaces can make it difficult to understand which methods are relevant to a given class. And that can result in unnecessary dependencies between classes. ISP benefits systems expected to evolve over time. As new features are added and old ones are deprecated, it is important to make changes to the code without breaking existing functionality. By following ISP, you can ensure that your code remains flexible.Example Before Imagine a company’s HR system with an interface called IEmployee. This interface includes methods such as:
GetSalary
GetVacationDays
GetPerformanceReview
In the code below, both FullTimeEmployee and PartTimeEmployee
were forced to implement the GetPerformanceReview
method even though it may not be applicable to PartTimeEmployees
. This violates ISP as it creates unnecessary coupling between the interface and the implementation.
public interface IEmployee {
double GetSalary();
int GetVacationDays();
string GetPerformanceReview();
}
public class FullTimeEmployee : IEmployee {
public double GetSalary() { /* Implementation */ }
public int GetVacationDays() { /* Implementation */ }
public string GetPerformanceReview() { /* Implementation */ }
}
public class PartTimeEmployee : IEmployee {
public double GetSalary() { /* Implementation */ }
public int GetVacationDays() { /* Implementation */ }
public string GetPerformanceReview() { /* Implementation */ }
}
After
In the code below, the HR system is updated to:
- move the salary information to
ISalariedEmployee
(away from the vacation information) - move the performance review information into the
IPerformanceReview
interface
This allows PartTimeEmployee
to only implement the methods that are applicable to them, reducing the unnecessary coupling between the interface and the implementation.
public interface IEmployee {
double GetSalary();
int GetVacationDays();
}
public interface IPerformanceReview {
string GetPerformanceReview();
}
public class FullTimeEmployee : ISalariedEmployee, IPerformanceReview {
public double GetSalary() { /* Implementation */ }
public int GetVacationDays() { /* Implementation */ }
public string GetPerformanceReview() { /* Implementation */ }
}
public class PartTimeEmployee : ISalariedEmployee {
public double GetSalary() { /* Implementation */ }
public int GetVacationDays() { /* Implementation */ }
}
Dependency Inversion Principle
DIP encourages testability
DIP states that high-level modules should not depend on low-level modules, both should depend on abstractions. Decoupling the different parts of the code makes it easier to maintain and change.
To follow DIP, you’ll need to use the two techniques below:
- “Dependency Injection” – A design pattern where the components of a software system are decoupled from each other, and their dependencies are managed by a third-party component called the “dependency injector”. This way you can replace or update components without disturbing the entire system.
- “Inversion of Control” – The mechanism by which the control of the flow of a system is inverted. Instead of the components calling each other directly, the control is given to a central component that manages the flow of the system. This central component is responsible for creating, managing, and injecting the dependencies into the components that need them.
Example
The example below follows DIP because it has high-level modules (the ContactService
class) depending on abstractions (the IContactRepository
and IValidator<>interfaces
) instead of concrete implementations.
- The concrete implementations (the actual repositories and validators) are passed to the high-level modules through the constructor (Dependency Injection), allowing for loose coupling between the modules.
- The use of interfaces (
IContactRepository
and Ivalidator<>interfaces
) allows for greater flexibility and ease of changing the implementation of the lower-level modules without affecting the high-level modules.
public class ContactService : IContactService {
private readonly IContactRepository _contactRepository;
private readonly IValidator _contactValidation;
private readonly ILogger _logger;
public ContactService(IContactRepository contactRepository,
Ivalidator _contactValidator,
ILoggerFactory loggerFactory) {
_contactRepository = contactRepository;
_contactValidation = _contactValidator;
_logger = loggerFactory.CreateLogger();
}
// <summary>
// Creates a contact validating the contact and calling the contactRepository
// </summary>
// <param name="contact"> The contact to be inserted </param>
// <returns> The contact that was inserted </returns>
public async Task CreateContact(Contact contact){
_logger.LogInformation($"Executing CreateContact on {nameof(ContactService)} with contact: " + $"{JsonConvert.SerializeObject(contact)}"};
_contactValidation.Validate(contact);
var result = await _contactRepository.InsertContact(contact);
_logger.LogInformation($"Finalizing executing CreateContact with result {JsonConvert.SerializeObject(result)}");
return result;
}
}
//This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services) {
services.AddRouting(options => options.LowercaseUrls = true);
services.AddDbContext(options =>
options.UseSqlServer(Configuration.GetConnectionString("Connection")));
services.AddScoped();
services.AddScoped();
services.AddScoped(typeof(IValidator<>), typeof(ValidationBase<>));
ILoggerFactory logger = services.BuildServiceProvider().GetRequiredService();
}
Conclusion
Here at Perform we are experts in software development, leveraging SOLID principles for optimal results. By prioritizing code readability, modularity, and consistency, we can create high-quality solutions. Our expertise in SOLID principles enables seamless collaboration among developers, leading to increased productivity and exceptional teamwork. Clients can rely on us to deliver efficient software built on the solid foundation of SOLID principles.
Whether it’s the holiday season or a big product launch with a heavy influx of traffic, we have the expertise to instill SOLID development principles and frameworks to drive maintainability, scalability and robust code for your company.
Contact us today to learn how we can help you achieve your software development and testing goals.