In this article we will explore the five SOLID design principles and how you can use them to make your code more robust and maintainable.
What is SOLID?
Solid is a set of five design principles devised by Robert C. Martin in his 2000 paper Design Principles and Design Patterns. In the paper Martin outlines the symptoms and causes of software rot and how these can be remedied with five object oriented class design principles.
Note however that in his paper these principles did not use the SOLID acronym. This term was keyed later in 2004 by Michael Feathers.
The five SOLID Principles
The Single Responsibility Principle
The single responsibility principle states that a class should only have one reason to change. In other words that a class should only one responsibility. This is to make classes more robust by having them focus on only one responsibility.
For example consider a class that takes input from a website form and puts the form data on a database and then sends a reply email to the user. These could be classed as two separate responsibilities and thus should be two separate classes. One class for putting the form data on to the database and another class for emailing the user.
2. The Open Closed Principle
The open closed principle states that a module should be open for extension but closed for modification. This means that modules should be designed such that new features can be added by adding new code but without having to modify existing code.
For example consider the code below that draws a circle.
public class Drawing { public void DrawCircle() { Console.WriteLine("Drawing a circle"); } }
This is all very well and good but what if we want the code to draw a square? Well we will have to modify the drawing class in order to add that functionality. As the number of features is added the more complex and cumbersome the drawing class will become.
A much better way is to define a shape interface and then create concrete classes for each shape that we want to add. This will mean that we do not have to keep modifying the drawing class every time we want to add a new shape. By doing this we leave the module open to extension but closed for modification.
Drawing drawing = new Drawing(); IShape circle = new Circle(); IShape square = new Square(); drawing.DrawShape(circle); // Output: Drawing a circle drawing.DrawShape(square); // Output: Drawing a square // Define a shape interface public interface IShape { void Draw(); } // Create concrete shape classes public class Circle : IShape { public void Draw() { Console.WriteLine("Drawing a circle"); } } public class Square : IShape { public void Draw() { Console.WriteLine("Drawing a square"); } } // Modify the Drawing class to work with any shape public class Drawing { public void DrawShape(IShape shape) { shape.Draw(); } }
3. The Liskov Substitution Principle
The Liskov substitution principle states that subclasses should be substitutable for their base classes. This means that a function that is a user of a base class should work as intended if a derivative of that class is passed to it. The advantage of this is that it allows for code reusability and reduced coupling.
For example consider the code below we have a Bird base class and two subclasses, Penguin and Eagle. All birds can fly, but penguins cannot fly. To adhere to the LSP, we should be able to substitute Penguin for Bird without issues.
using System; class Bird { public virtual void Fly() { Console.WriteLine("A bird can fly"); } } class Penguin : Bird { public override void Fly() { Console.WriteLine("Penguins cannot fly"); } } class Eagle : Bird { public override void Fly() { Console.WriteLine("An eagle can fly"); } } class Program { static void MakeBirdFly(Bird bird) { bird.Fly(); } static void Main() { Bird bird1 = new Penguin(); Bird bird2 = new Eagle(); MakeBirdFly(bird1); // Output: Penguins cannot fly MakeBirdFly(bird2); // Output: An eagle can fly } }
4. The Interface Segregation Principle
The interface segregation principle states that many client specific interfaces are better than one general purpose interface. This means that instead of loading methods for all clients you should instead create an interface for each client with only the methods that specific client requires.
For example consider the code below:
// Define a document interface public interface IDocument { void Create(); void Format(); void Save(); } // Implement the IDocument interface in a TextDocument class public class TextDocument : IDocument { public void Create() { Console.WriteLine("Creating a text document."); } public void Format() { Console.WriteLine("Formatting the text document."); } public void Save() { Console.WriteLine("Saving the text document."); } } // Implement the IDocument interface in a PdfDocument class public class PdfDocument : IDocument { public void Create() { Console.WriteLine("Creating a PDF document."); } public void Format() { Console.WriteLine("Formatting the PDF document."); } public void Save() { Console.WriteLine("Saving the PDF document."); } } // Client code that uses IDocument public class DocumentEditor { public void EditDocument(IDocument document) { document.Create(); document.Format(); document.Save(); } } class Program { static void Main() { DocumentEditor editor = new DocumentEditor(); IDocument textDocument = new TextDocument(); IDocument pdfDocument = new PdfDocument(); Console.WriteLine("Editing a Text Document:"); editor.EditDocument(textDocument); Console.WriteLine("\nEditing a PDF Document:"); editor.EditDocument(pdfDocument); } }
In this example, we have the IDocument interface, which contains methods for creating, formatting, and saving documents. However, some classes (such as TextDocument and PdfDocument) may not need all these methods. The principle is demonstrated because clients (in this case, the DocumentEditor class) can depend on the specific interfaces they need without being forced to implement methods they don't use.
5. The Dependency Inversion Principle
The dependency inversion principle states that one should depend on interfaces and abstract functions and classes rather than concrete functions and classes. The reason for this is that it allows for less coupling between components and thus less dependence on each other which in turn leads to more robust code.
For example consider the code below for modelling a light bulb. The Switch class depends on the ISwitchable interface instead of the concrete LightBulb class. This adheres to the Dependency Inversion Principle because high-level modules (like Switch) depend on abstractions (ISwitchable), and low-level modules (like LightBulb) implement those abstractions, allowing for better flexibility and easier substitution of components.
// Abstraction: ISwitchable public interface ISwitchable { void TurnOn(); void TurnOff(); } // Low-level module: LightBulb (implements ISwitchable) public class LightBulb : ISwitchable { public void TurnOn() { Console.WriteLine("Light bulb is on."); } public void TurnOff() { Console.WriteLine("Light bulb is off."); } } // High-level module: Switch (depends on ISwitchable) public class Switch { private readonly ISwitchable device; public Switch(ISwitchable device) { this.device = device; // Dependency injection via constructor } public void FlipOn() { device.TurnOn(); } public void FlipOff() { device.TurnOff(); } } class Program { static void Main() { ISwitchable bulb = new LightBulb(); // Create a LightBulb instance Switch lightSwitch = new Switch(bulb); // Inject the instance into the Switch lightSwitch.FlipOn(); lightSwitch.FlipOff(); } }