What are SOLID principals and why are they so important?

Mon, Jun 24, 2019

Read in 7 minutes

I've been helping out a few friends lately with code reviews and noticed a decline in the use of SOLID principals. When questioned as to why they aren't writing SOLID code, the answer is almost always due to time pressures. I must admit, I am equally guilty of this!

What are SOLID principals and why are they so important?

What will following SOLID principals give me?

SOLID principals are essential to any software engineers toolkit. The end result of following the conventions is code that is understandable, easier to maintain and easier to extend. As a result, you will end up with a stronger code base that is easier to update and change with minimal technical debt. The days of “I wish I could rewrite ……” will largely become a thing of the past.

But I am under a lot of pressure and need to produce code fast!

Following SOLID principals only adds a minimal amount of time to the development process. However, you rapidly make back this development time and more as you reuse components and build on top of what you have already created.

Ok Ok … What are the SOLID Principals?

S: Single responsibility principle

You don’t want classes that are jack of all trades and masters of none. Do one thing and do it well!

Let say you need to open a database connection, get data and output the results to a file. This is significant amount of operations and a multitude of future changes could break the class. What if we change the database provide, use a different ORM or change how we output the data? Any of the 3 will break the code base.

Let’s take this method:

class Foo{
    void Add(Database db)
    {
        try
        {
            db.Add();
        }
        catch (Exception ex)
        {
            File.WriteAllText(@"C:\Errors.log", ex.ToString());
        }
    }
}

What happens if we no longer write logs to the that location on the file system? How many other classes and methods need to be changed? Let’s fix this code!

class Foo{
    private FileLogger logger = new FileLogger();
    void Add(Database db)
    {
        try {
            db.Add();
        }
        catch (Exception ex)
        {
            logger.Handle(ex.ToString());
        }
    }
}
class FileLogger
{
    void Handle(string error)
    {
        File.WriteAllText(@"C:\Errors.txt", error);
    }
}

O: Open/Closed Principle

This principle suggests that “classes, modules and functions should be open for extension but closed for modification”. By following this method, code that requires changing is extended. This leaves the original code in tact and minimises breaking changes.

Lets look at some bad design:

class Customer
{
    void Add(Database db)
    {
        if (CustomerType == eCustomerType.Wholesale)
        {
            db.AddWholesale();
        }
        else
        {
            db.Add();
        }
    }
}

What happens if we now sell to distributors? We need to modify the Add method. Changes always carry risk so we need to find a way of extended the existing code.

class Customer{
    void Add(Database db)
    {
        db.Add();
    }
}
class WholesaleCustomer : Customer{
    override void Add(Database db)
    {
        db.AddWholesaleCustomer();
    }
}
class DistributionCustomer : Customer
{
    override void Add(Database db)
    {
        db.AddDistributionCustomer();
    }
}

L: Liskov substitution principle

This principle states that a parent class should be easily substituted with their child classes without breaking the application (although there may be some loss in functionality).

Lets take a look at a customer loyalty system:

public class Customer
{
    public virtual double GetDiscount(double totalSales)
    {
        return totalSales;
    }
    public virtual void AddLoyaltyPoints(int points)
    {
        Console.WriteLine($"Adding {points} points to Customer's loyalty account");
    }
}
public class GoldCustomer : Customer
{
    public override double GetDiscount(double totalSales)
    {
        var total = base.GetDiscount(totalSales);
        return total - (total * .75); //25% off
    }
    public override void AddLoyaltyPoints(int points)
    {
        Console.WriteLine($"Adding {points} points to Gold Customer's loyalty account");
    }
}

Looks good so far? But what if we add Leads to the system. They are capable of getting a discount but can’t earn loyalty points. The best we can do is:

public class Lead : Customer
{
    public override double GetDiscount(double totalSales)
    {
        var total = base.GetDiscount(totalSales);
        return total - (total * .1); //10% off
    }
    public override void AddLoyaltyPoints(int points)
    {
        throw new Exception("Illegal Operation. Leads have no loyalty account");
    }
}

The problem might not be immediately obvious until we start iterating over our Customers and add points for a special event.

var customers = new List<Customer>
{
    new Customer(),
    new SilverCustomer(),
    new GoldCustomer(),
    new Lead()
};
foreach (var customer in customers)
{
    customer.AddLoyaltyPoints(1);
}

Now, as soon as we get the Lead and call the AddLoyaltyPoints methods, we are going to hit the exception and the application will crash. So, how can we handle this? Firstly, we need to identify the problem. A Lead is not actually a Customer but they could become a Customer. We need to separate the concerns here. Discount and Loyalty Points are 2 different concerns and should not be dependent on each other. Lets create some interfaces to fix this.

public interface IHasDiscount
{
    double GetDiscount(double totalSales);
}
public interface IHasLoyalty
{
    void AddLoyaltyPoints(int points);
}

Now lets amend out classes to use the interfaces

public class Customer : IHasDiscount, IHasLoyalty
{
    public virtual double GetDiscount(double totalSales)
    {
        return totalSales;
    }
    public virtual void AddLoyaltyPoints(int points)
    {
        Console.WriteLine($"Adding {points} points to Customer's loyalty account");
    }
}
public class GoldCustomer : Customer
{
    public override double GetDiscount(double totalSales)
    {
        var total = base.GetDiscount(totalSales);
        return total - (total * .5); //50% off
    }
    public override void AddLoyaltyPoints(int points)
    {
        Console.WriteLine($"Adding {points} points to Gold Customer's loyalty account");
    }
}

The Customer is using both interfaces and, as a result, has the same functionality. Now lets add the Lead back in.

public class Lead : IHasDiscount
{
    public double GetDiscount(double totalSales)
    {
        return totalSales - (totalSales * .1); //10% off
    }
}

Now, with proper separation, lets add them Loyalty Points again.

var loyaltyCustomers = new List<IHasLoyalty>()
{
    new solid.Customer(),
    new solid.SilverCustomer(),
    new solid.GoldCustomer(),
};
var discountCustomers = new List<IHasDiscount>()
{
    new solid.Customer(),
    new solid.SilverCustomer(),
    new solid.GoldCustomer(),
    new solid.Lead()
};
foreach (var loyalCustomer in loyaltyCustomers)
{
    loyalCustomer.AddLoyaltyPoints(1);
}
foreach (var discountCustomer in discountCustomers)
{
    Console.WriteLine($"Discounted price for member level " +
                        $"'{discountCustomer.GetType().Name}' is " +
                        $"${discountCustomer.GetDiscount(100.50):N2}");
}

I: Interface segregation principle

After the headache above, let’s move onto something much simpler. The ISP splits interfaces that are very large into smaller and more specific ones so that clients will only have to know about the methods that are of interest to them. Lets take the following example:

public class SpeedyMcSpeedyFace : Athlete{
    public override void Compete()
    {
        Console.WriteLine("SpeedyMcSpeedyFace is competing!");
    }
    public override void Swim()
    {
        Console.WriteLine("SpeedyMcSpeedyFace is swimming!");
    }
    public override void HighJump()
    {
    }
    public override void LongJump()
    {
    }
}

In the example above, our swimmer has extra methods that are not required. This is equally true for a jumper. Lets refactor the code the align with the ISP principal.

public interface Athlete{
    void Compete();
}public interface SwimmingAthlete : Athlete{
    void Swim();
}public interface JumpingAthlete : Athlete{
    void HighJump();
    void LongJump();
}

And now lets implement our athlete.

public class SpeedyMcSpeedyFace : SwimmingAthlete {
    public override void Compete() {
        Console.WriteLine("SpeedyMcSpeedyFace is competing!");
    }
    public override void swim() {
        Console.WriteLine("SpeedyMcSpeedyFace is swimming!");
    }
}

D: Dependency inversion principle

In object-oriented design, the dependency inversion principle is a specific form of decoupling software modules. Lets complete the breakdown of SOLID principals by bring this back to the first example and fixing it to even further to align with the complete principal set.

class Foo{
    private FileLogger logger = new FileLogger();
    void Add(Database db)
    {
        try {
            db.Add();
        }
        catch (Exception ex)
        {
            logger.Handle(ex.ToString());
        }
    }
}

In the example above, we create an instance of the FileLogger inside the class. But what if we want to use a different type of logger? We would need to modify the class. Lets fix this by using dependency injection.

class Foo{
    private Logger _logger;
    void Foo(Logger logger)
    {
        _logger = logger;
    }
    void Add(Database db)
    {
        try {
            db.Add();
        }
        catch (Exception ex)
        {
            logger.log(ex.ToString());
        }
    }
}

Conclusion

By applying these 5 essential principles used by professional software engineers we get to benefit from a reusable, maintainable, scalable and easy testable codebase.