Home » Blog » SOLID Principles – A Guide to Writing Better Code

SOLID Principles – A Guide to Writing Better Code

solid-principles

When it comes to developing software, it’s essential to follow best practices to create maintainable and scalable code. One of the most widely recognized and respected sets of principles is SOLID.

In this post, we’ll take a closer look at each of the SOLID principles and how they can be applied in .NET development, with useful code examples.

Single Responsibility Principle (SRP)

The SRP states that a class should have only one reason to change. In other words, a class should have only one responsibility and should be responsible for only one part of the functionality provided by the software.

For example, consider the following code:

class User
{
    public string Name { get; set; }
    public string Email { get; set; }
    public void SaveToDatabase()
    {
        // Save user to the database
    }
}

In this example, the User class has multiple responsibilities, it holds user data and also saves the data to the database, which violates the SRP. To follow the SRP, we can create a separate class for saving the data to the database:

class User
{
    public string Name { get; set; }
    public string Email { get; set; }
}
class UserRepository
{
    public void Save(User user)
    {
        // Save user to the database
    }
}

Open/Closed Principle (OCP)

The OCP states that a class should be open for extension but closed for modification. This means that the behavior of a class should be able to be extended without modifying the class itself.

For example, consider the following code:

interface IShape
{
    double Area();
}

class Rectangle : IShape
{
    private double _width;
    private double _height;
    public Rectangle(double width, double height)
    {
        _width = width;
        _height = height;
    }
    public double Area()
    {
        return _width * _height;
    }
}

class Circle : IShape
{
    private double _radius;
    public Circle(double radius)
    {
        _radius = radius;
    }
    public double Area()
    {
        return Math.PI * _radius * _radius;
    }
}

class AreaCalculator
{
    public double CalculateArea(IShape[] shapes)
    {
        double area = 0;
        foreach (var shape in shapes)
        {
            area += shape.Area();
        }
        return area;
    }
}

In this example, the IShape interface defines a single method, Area(). The Rectangle and Circle classes implement this interface and provide their own implementation of the Area() method.

The AreaCalculator class accepts an array of IShape objects and calculates the total area of all the shapes by summing the area of each shape. The AreaCalculator class is open for extension because new shapes can be added (e.g. a triangle class) without modifying the AreaCalculator class, and it is closed for modification because the AreaCalculator class does not need to be modified to handle new shapes.

By using an interface, the IShape is closed for modification because it only defines the contract that all the classes that implement it must follow, but open for extension, because new shapes can be added by creating new classes that implement the IShape interface, without modifying the existing classes.

In this example, the AreaCalculator class is closed for modification because it does not need to change when new shapes are added. The IShape interface is open for extension because new shapes can be added without modifying the existing interface.

Liskov Substitution Principle (LSP)

The LSP states that derived classes should be substitutable for their base classes. This means that objects of a superclass should be able to be replaced with objects of a subclass without affecting the correctness of the program.

For example, consider the following code:

class Shape
{
    public virtual double Area()
    {
        return 0;
    }
}

class Rectangle : Shape
{
    private double _width;
    private double _height;
    public Rectangle(double width, double height)
    {
        _width = width;
        _height = height;
    }
    public override double Area()
    {
        return _width * _height;
    }
}

class Square : Shape
{
    private double _side;
    public Square(double side)
    {
        _side = side;
    }
    public override double Area()
    {
        return _side * _side;
    }
}

In this example, the Shape class is the base class, and Rectangle and Square are derived classes. The Area method is defined in the base class and overridden in the derived classes to provide the specific implementation of the area calculation for each shape.

The Liskov Substitution Principle states that derived classes should be substitutable for their base classes. This means that we should be able to use an instance of a derived class wherever an instance of the base class is expected, and the program should continue to function correctly.

In this example, we can use a Rectangle instance or a Square instance wherever a Shape instance is expected, and the Area method will be called correctly for each class. This allows for more flexibility in the code and a more robust design.

Interface Segregation Principle (ISP)

The ISP states that a class should not be forced to implement interfaces it does not use. This means that a class should only implement the methods and properties that it needs to, rather than being forced to implement a large number of methods and properties that it doesn’t need.

For example, consider the following code:

interface IShape {
    void Draw();
    void Scale();
    void Rotate();
}
class Circle : IShape {
    public void Draw() {
        Console.WriteLine("Drawing a circle.");
    }
    public void Scale() {
        Console.WriteLine("Scaling a circle.");
    }
    public void Rotate() {
        Console.WriteLine("Rotating a circle.");
    }
}

In this example, the Circle class implements the IShape interface, which includes methods for drawing, scaling and rotating a shape. However, not all shapes can be scaled or rotated, so it makes more sense to create separate interfaces for each operation.

Let’s look at the modified approach:

interface IDrawable
{
    void Draw();
}
interface IScalable
{
    void Scale();
}
interface IRotatable
{
    void Rotate();
}

class Circle : IDrawable
{
    public void Draw()
    {
        Console.WriteLine("Drawing a Circle");
    }
}
class Square : IDrawable, IScalable, IRotatable
{
    public void Draw()
    {
        Console.WriteLine("Drawing a Square");
    }
    public void Scale()
    {
        Console.WriteLine("Scaling a Square");
    }
    public void Rotate()
    {
        Console.WriteLine("Rotating a Square");
    }
}

In this example, we have three interfaces: IDrawable, IScalable, and IRotatable. Each interface defines a single method that describes a particular functionality.

The Circle class only needs to implement the IDrawable interface, since it only needs the ability to draw itself. However, the Square class needs to be able to draw, scale, and rotate itself, so it implements all three interfaces.

By segregating the functionality in different interfaces, it also allows for more flexibility in terms of implementation, as we could have different implementations for the same functionality.

Dependency Inversion Principle (DIP)

The DIP states that high-level modules should not depend on low-level modules, but both should depend on abstractions. This means that code should depend on abstractions rather than concretions.

For example, consider the following code:

class UserService {
    private MySQLDatabase _database;
    public UserService() {
        _database = new MySQLDatabase();
    }
    public void SaveUser(string user) {
        _database.SaveData(user);
    }
}

In this example, the UserService class is dependent on the MySQLDatabase class, and it creates an instance of the class within its constructor. This creates a tight coupling between the two classes and makes it difficult to change the database without affecting the UserService class.

To follow the DIP, we can create an interface for the database and depend on abstraction instead of concrete implementation:

interface IDatabase {
    void SaveData(string data);
}

class MySQLDatabase : IDatabase {
    public void SaveData(string data) {
        // Save data to MySQL database
    }
}
class MongoDatabase : IDatabase {
    public void SaveData(string data) {
        // Save data to Mongo database
    }
}
class UserService {
    private IDatabase _database;
    public UserService(IDatabase database) {
        _database = database;
    }
    public void SaveUser(string user) {
        _database.SaveData(user);
    }
}

Conclusion

Each SOLID principle addresses a specific aspect of software design and development, but they all work together to create a cohesive and robust codebase.

By following these principles, developers can create code that is more robust, flexible, and easier to maintain over time.

Leave a Reply

Your email address will not be published. Required fields are marked *