SOLID principles are essential guidelines designed to create code that’s robust, maintainable, and scalable. Coined by Robert C. Martin, these principles — Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion—provide a roadmap for writing code that is not only functional but also flexible and easy to understand. By adhering to SOLID principles, developers can craft software architectures that withstand the test of time, ensuring their code remains adaptable and resilient to change.
🤡
Why was the JavaScript developer sad?
Because he didn’t Node how to Express himself.
What are SOLID Principles?
Single Responsibility Principle (SRP)
Every class should have one job and do it well. It keeps code clean and focused.
Open/Closed Principle (OCP)
Code should be open for extension (adding new features) but closed for modification (changing existing code).
Liskov Substitution Principle (LSP)
Subclasses should be usable in place of their parent classes without unexpected behavior.
Interface Segregation Principle (ISP)
Clients should not be forced to depend on interfaces they don’t use. Keep interfaces small and focused.
Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions. It makes code flexible and easy to change.
Single Responsibility Principle (SRP)
SRP states that a class should have only one reason to change, making code easier to understand and maintain.
Example of Violating SRP
Consider a class Employee
that handles both storing employee data and calculating bonuses:
public class Employee
{
public string Name { get; set; }
public double Salary { get; set; }
public double CalculateBonus()
{
// Calculation logic for bonus
}
}
Refactoring to Adhere to SRP
To follow SRP, split responsibilities into separate classes:
public class Employee
{
public string Name { get; set; }
public double Salary { get; set; }
}
public class BonusCalculator
{
public double CalculateBonus(Employee employee)
{
// Calculation logic for bonus
}
}
Now, the Employee
class only manages employee data, while the BonusCalculator
class handles bonus calculations. This separation makes the code easier to understand and modify.
Open/Closed Principle (OCP)
The Open/Closed Principle (OCP) in software development suggests that classes should be open for extension but closed for modification.
Example
Suppose we have a simple Shape
class hierarchy:
public abstract class Shape
{
public abstract double Area();
}
public class Circle : Shape
{
public double Radius { get; set; }
public override double Area()
{
return Math.PI * Radius * Radius;
}
}
public class Square : Shape
{
public double SideLength { get; set; }
public override double Area()
{
return SideLength * SideLength;
}
}
Now, let’s say we want to add a new shape, a Rectangle
, without modifying the existing code:
public class Rectangle : Shape
{
public double Length { get; set; }
public double Width { get; set; }
public override double Area()
{
return Length * Width;
}
}
Here, we’ve extended our Shape
class hierarchy with the Rectangle
class without altering the existing Shape
, Circle
, or Square
classes.
Techniques
- Inheritance: We use inheritance to create new shapes (
Circle
,Square
,Rectangle
) without changing the existing shape classes. This keeps the original code intact. - Polymorphism: Each shape class implements its own
Area()
method, but they all adhere to theShape
class’s contract. This allows treating all shapes uniformly, promoting code flexibility and reusability.
By adhering to OCP, we ensure that our code is easier to maintain and extend, making it more robust and adaptable to future changes.
Liskov Substitution Principle (LSP)
The Liskov Substitution Principle (LSP) states that objects of a superclass should be replaceable with objects of its subclasses without altering the correctness of the program.
Example
Consider a scenario with shapes: rectangles and squares.
Violation
class Rectangle
{
public int Width { get; set; }
public int Height { get; set; }
public int Area() => Width * Height;
}
class Square : Rectangle
{
public new int Width
{
get => base.Width;
set { base.Width = value; base.Height = value; }
}
public new int Height
{
get => base.Height;
set { base.Width = value; base.Height = value; }
}
}
In this example, Square
inherits from Rectangle
, but it violates LSP because changing the width or height of a Square
changes both to maintain the square shape, which contradicts the behavior expected from a Rectangle
.
Resolution
abstract class Shape
{
public abstract int Area();
}
class Rectangle : Shape
{
public int Width { get; set; }
public int Height { get; set; }
public override int Area() => Width * Height;
}
class Square : Shape
{
public int Size { get; set; }
public override int Area() => Size * Size;
}
In this revised version, both Rectangle
and Square
inherit from a common base class Shape
, ensuring they conform to the same interface. Now, substituting objects of Rectangle
or Square
should not alter the correctness of the program, satisfying LSP.
This example demonstrates how adhering to LSP can prevent unexpected behavior and how restructuring the inheritance hierarchy can maintain substitutability without compromising program correctness.
Interface Segregation Principle (ISP)
ISP is about creating interfaces that are specific to what each class needs, avoiding unnecessary methods. This makes code cleaner and easier to maintain.
Example
Imagine we have a Machine
interface with methods like Start()
, Stop()
, Operate()
, and Maintain()
.
public interface Machine {
void Start();
void Stop();
void Operate();
void Maintain();
}
Violation of ISP
If we have a class ConveyorBelt
that only needs to start and stop, implementing Machine
forces it to include unnecessary methods:
public class ConveyorBelt : Machine {
public void Start() {
// Start the conveyor belt
}
public void Stop() {
// Stop the conveyor belt
}
// Unnecessary methods
public void Operate() {
// Not needed for a conveyor belt
}
public void Maintain() {
// Not needed for a conveyor belt
}
}
Adhering to ISP
To follow ISP, we split the Machine
interface into smaller, focused interfaces:
public interface IStarter {
void Start();
}
public interface IStoppable {
void Stop();
}
Now, ConveyorBelt
can implement only what it needs:
public class ConveyorBelt : IStarter, IStoppable {
public void Start() {
// Start the conveyor belt
}
public void Stop() {
// Stop the conveyor belt
}
}
By following ISP, we keep interfaces focused and classes clean. This improves code readability and makes it easier to maintain and extend our software.
Dependency Inversion Principle (DIP)
Dependency Inversion Principle (DIP) is about making your code flexible, easy to maintain, and testable by reducing tight connections between different parts of your program.
How DIP Works
Instead of high-level parts of your code depending directly on low-level details, both should rely on abstract concepts, like interfaces or abstract classes. This promotes flexibility and makes your code less prone to breaking when you make changes.
Example
Let’s say we have a Logger
class:
public class Logger
{
public void Log(string message)
{
Console.WriteLine($"Logging: {message}");
}
}
Traditionally, a high-level class might directly depend on Logger
:
public class HighLevelService
{
private Logger _logger = new Logger();
public void DoSomething()
{
// Some logic
_logger.Log("Something happened");
}
}
With DIP, we introduce an interface, ILogger
:
public interface ILogger
{
void Log(string message);
}
Now, our Logger
class implements this interface:
public class Logger : ILogger
{
public void Log(string message)
{
Console.WriteLine($"Logging: {message}");
}
}
And our HighLevelService
depends on the ILogger
interface:
public class HighLevelService
{
private ILogger _logger;
// Dependency injection through constructor
public HighLevelService(ILogger logger)
{
_logger = logger;
}
public void DoSomething()
{
// Some logic
_logger.Log("Something happened");
}
}
Now, HighLevelService
doesn’t directly depend on Logger
. Instead, it depends on the abstract concept of logging (ILogger
). This adheres to the Dependency Inversion Principle, making your code more adaptable and easier to manage.
Benefits of Applying SOLID Principles
Implementing SOLID principles in software development brings several advantages:
Improved Code Quality
SOLID principles lead to cleaner, more understandable code that’s less error-prone. By emphasising clear design and organisation, developers produce higher-quality software.
Easier Maintenance
SOLID principles make codebases easier to maintain by promoting modularity and minimising unexpected side effects. This means developers can update and fix code more efficiently.
Enhanced Scalability
With SOLID principles, software architectures are built to handle growth and change gracefully. Systems remain flexible and adaptable as they evolve, without sacrificing stability.
Better Testability
SOLID principles encourage writing modular, isolated tests, making it easier to verify the correctness of each component. This leads to more thorough testing and earlier detection of issues.
Challenges and Considerations
Challenges
- Increased Complexity: SOLID principles might seem complex at first, especially for those used to traditional coding.
- Upfront Time Investment: Refactoring to align with SOLID principles can take time, especially in large codebases.
- Resistance to Change: Some developers might resist adopting SOLID principles due to unfamiliarity or disruption.
- Balancing Principles: It can be tricky to balance multiple SOLID principles within a single codebase or feature.
- Team Collaboration: Ensuring consistent application of SOLID principles across a team can be challenging, especially in large or distributed teams.
Tips for Overcoming
- Start Small: Begin with simple refactoring and gradually introduce more complex principles.
- Prioritise Efforts: Focus refactoring on areas of the codebase most prone to change or with the highest impact.
- Provide Support: Offer resources, training, and support to help developers understand and embrace SOLID principles.
- Balance and Adapt: Prioritise principles based on current development goals and adapt as needed.
- Encourage Collaboration: Foster open communication and establish code review processes that include SOLID principles.