.NET design pattern refers to a set of reusable solutions for commonly occurring software design problems in the .NET framework. These design patterns provide a proven solution to a specific problem and can help to improve code quality, maintainability, and scalability of .NET applications. Examples of .NET design patterns include Singleton, Factory, Builder, Decorator, and many others.
Why do we need a design pattern?
We need design patterns in software development for several reasons:
Reusability: Design patterns provide reusable solutions to common software design problems. They have been tested and proven over time and can be used as building blocks in new applications.
Maintainability: Design patterns promote code consistency and help to simplify maintenance. Since they provide a standard approach to solving problems, it's easier to maintain code written with design patterns.
Scalability: Design patterns help to build scalable software. They provide a foundation for building flexible and extensible applications that can be adapted to changing requirements.
Efficiency: Design patterns can help to increase the efficiency of software development. Since they provide a structured approach to solving problems, developers can focus on implementing business logic rather than worrying about low-level details.
Collaboration: Design patterns provide a common language for developers. They help to improve communication and collaboration within development teams, making it easier to share code and knowledge.
In this article, we are going to learn SOLID design pattern principles step by step with examples
SOLID Design Principles
S : Single Responsibility Principle (S.R.P).
O : Open-closed Principle (O.C.P).
L : Liskov Substitution Principle (L.S.P).
I : Interface Segregation Principle (I.S.P).
D : Dependency Inversion Principle (D.I.P).
Single Responsibility Principle
The Single Responsibility Principle (SRP) is a design principle in object-oriented programming that states that a class should have only one reason to change. In other words, a class should have only one responsibility or job.
Let's take the example of a journal where you write down your thoughts. To apply the SRP, we can create separate classes for the journal and the entries.
First, we can create a Journal class that will be responsible for managing the entries in the journal:
public class Journal
{
private List<JournalEntry> entries = new List<JournalEntry>();
public void AddEntry(JournalEntry entry)
{
entries.Add(entry);
}
public void RemoveEntry(int index)
{
entries.RemoveAt(index);
}
public List<JournalEntry> GetEntries()
{
return entries;
}
}
In the above code, the Journal class has only one responsibility, which is to manage the entries in the journal. It has methods for adding and removing entries, as well as retrieving all the entries in the journal.
Next, we can create a JournalEntry class that will be responsible for representing an entry in the journal:
public class JournalEntry
{
public DateTime Date { get; set; }
public string Content { get; set; }
public JournalEntry(DateTime date, string content)
{
Date = date;
Content = content;
}
}
In the above code, the JournalEntry class has only one responsibility, which is to represent an entry in the journal. It has properties for the date and content of the entry, as well as a constructor for creating a new entry.
By separating the responsibilities of managing the entries and representing the entries into two separate classes, we have applied the Single Responsibility Principle. This makes our code more modular and easier to maintain and modify in the future.
Here is an example of how we can use these classes to create a journal:
Journal journal = new Journal();
JournalEntry entry1 = new JournalEntry(DateTime.Now, "Today was a good day.");
JournalEntry entry2 = new JournalEntry(DateTime.Now.AddDays(-1), "Yesterday was tough, but I got through it."); journal.AddEntry(entry1);
journal.AddEntry(entry2);
List<JournalEntry> entries = journal.GetEntries();
foreach (JournalEntry entry in entries)
{
Console.WriteLine(entry.Date.ToShortDateString() + " - " + entry.Content);
}
In the above code, we create a new journal, add two entries to it, and then retrieve all the entries and display them on the console. The Journal and JournalEntry classes each have only one responsibility, making the code easy to understand and modify as needed.
Open-Closed Principle
The Open-Closed Principle (OCP) is a design principle in object-oriented programming that states that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. In other words, you should be able to add new functionality to a system without having to modify the existing code.
Let's take the example of an ordering system for an e-commerce website. Suppose we have a Product class with different categories and traits, and we want to be able to add new categories and traits without having to modify the existing code.
First, we can define an interface for the Product class that defines the basic properties and methods that all products should have:
public interface IProduct
{
string Name { get; set; }
decimal Price { get; set; }
string Category { get; }
IDictionary<string, object> Properties { get; }
void AddProperty(string name, object value);
}
In the above code, the IProduct interface defines the basic properties and methods that all products should have, including the name, price, category, and properties (traits). It also includes a method for adding new properties to a product.
Next, we can create a base class for the Product class that implements the IProduct interface and provides some default functionality:
public abstract class ProductBase : IProduct
{
public string Name { get; set; }
public decimal Price { get; set; }
public abstract string Category { get; }
private IDictionary<string, object> _properties = new Dictionary<string, object>();
public IDictionary<string, object> Properties
{
get { return _properties; }
}
public void AddProperty(string name, object value)
{
_properties.Add(name, value);
}
}
In the above code, the ProductBase class implements the IProduct interface and provides default implementations for the name, price, properties, and the method for adding new properties. It also declares an abstract property for the category, which will be implemented by derived classes. Next, we can create concrete classes for each category of product, each of which derives from the ProductBase class and implements the abstract Category property:
public class ElectronicProduct : ProductBase
{
public override string Category
{
get
{
return "Electronics";
}
}
}
public class ClothingProduct : ProductBase
{
public override string Category
{
get
{
return "Clothing";
}
}
}
public class HomeProduct : ProductBase
{
public override string Category
{
get
{
return "Home";
}
}
}
In the above code, we create three concrete classes for different categories of products: ElectronicProduct, ClothingProduct, and HomeProduct. Each of these classes derives from the ProductBase class and implements the abstract Category property with a string value representing the category name.
Now, if we want to add a new category of products, we can simply create a new class that derives from the ProductBase class and implements the abstract Category property, without having to modify any of the existing code.
This is an example of how the Open-Closed Principle can be applied in the design of an ordering system for an e-commerce website. By using an interface and base class to define the common properties and methods of all products, and creating concrete classes for each category of products, we can add new categories without having to modify the existing code.
Liskov Substitution Principle
The Liskov Substitution Principle (LSP) is a design principle in object-oriented programming that states that objects of a superclass should be able to be replaced with objects of a subclass without affecting the correctness of the program.
Let's take the example of a Rectangle class in C# that has properties for its width and height, and a method to calculate its area:
public class Rectangle
{
public virtual int Width { get; set; }
public virtual int Height { get; set; }
public int GetArea()
{
return Width * Height;
}
}
In the above code, the Rectangle class has two properties for its width and height, and a method to calculate its area using the formula width * height.
Now suppose we want to create a Square class that derives from the Rectangle class and has a single property for its side length, but still needs to be able to calculate its area:
public class Square : Rectangle
{
public override int Width
{
get { return base.Width; }
set { base.Width = value; base.Height = value; }
}
public override int Height
{
get { return base.Height; }
set { base.Width = value; base.Height = value; }
}
}
In the above code, the Square class derives from the Rectangle class and overrides the Width and Height properties to ensure that they always have the same value (since a square has equal sides). This ensures that any code that expects a Rectangle object can still work correctly with a Square object.
However, this violates the Liskov Substitution Principle because a Square is not a true substitute for a Rectangle in all cases. For example, if there is a method that takes a Rectangle object and sets its width and height independently, this would not work correctly with a Square object.
To adhere to the Liskov Substitution Principle, we could instead create a separate Square class that does not derive from the Rectangle class and has its own implementation of the GetArea() method:
public class Square
{
public int SideLength { get; set; }
public int GetArea()
{
return SideLength * SideLength;
}
}
In the above code, we create a separate Square class with a single property for its side length and a method to calculate its area using the formula side length * side length. This ensures that any code that expects a Rectangle object can still work correctly with a Rectangle object, and any code that expects a Square object can still work correctly with a Square object, without the risk of unexpected behavior.
Interface Segregation Principle
The Interface Segregation Principle (ISP) is a design principle in object-oriented programming that states that no client should be forced to depend on methods it does not use.
Let's take an example of a hypothetical interface called IMachine in C# that has a method for printing, scanning, and faxing documents:
public interface IMachine
{
void Print(Document document);
void Scan(Document document);
void Fax(Document document);
}
In the above code, the IMachine interface has three methods for printing, scanning, and faxing documents.
Now suppose we have a class called MultiFunctionPrinter that implements the IMachine interface:
public class MultiFunctionPrinter : IMachine
{
public void Print(Document document){
// Implementation for printing
}
public void Scan(Document document){
// Implementation for scanning
}
public void Fax(Document document){
// Implementation for faxing
}
}
In the above code, the MultiFunctionPrinter class implements the IMachine interface and provides implementations for all three methods.
However, this violates the Interface Segregation Principle because not all clients that depend on the IMachine interface necessarily need all three methods. For example, a client that only needs to print documents would still be forced to depend on the Scan() and Fax() methods.
To adhere to the Interface Segregation Principle, we could instead break the IMachine interface into separate interfaces for printing, scanning, and faxing:
public interface IPrinter
{
void Print(Document document);
}
public interface IScanner
{
void Scan(Document document);
}
public interface IFax
{
void Fax(Document document);
}
In the above code, we create separate interfaces for printing, scanning, and faxing, each with its own method. Now, we can create a MultiFunctionPrinter class that implements all three interfaces:
public class MultiFunctionPrinter : IPrinter, IScanner, IFax
{
public void Print(Document document){
// Implementation for printing
}
public void Scan(Document document){
// Implementation for scanning
}
public void Fax(Document document){
// Implementation for faxing
}
}
In the above code, the MultiFunctionPrinter class implements all three interfaces and provides implementations for all three methods. However, any clients that only need to use one of the interfaces can do so without being forced to depend on the other methods. For example, a client that only needs to print documents can now depend only on the IPrinter interface.
Dependency Inversion Principle
The Dependency Inversion Principle (DIP) is a design principle in object-oriented programming that states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions.
Let's take an example of a hypothetical application that needs to send notifications via email and SMS. We have two classes EmailSender and SMSSender that handle sending email and SMS respectively:
public class EmailSender
{
public void SendEmail(string recipient, string message)
{
// Implementation for sending email
}
}
public class SMSSender
{
public void SendSMS(string recipient, string message)
{
// Implementation for sending SMS
}
}
Now, suppose we have a NotificationService class that needs to send notifications via email and SMS. One way to implement this would be to directly create instances of EmailSender and SMSSender in the NotificationService class:
public class NotificationService
{
private EmailSender emailSender = new EmailSender();
private SMSSender smsSender = new SMSSender();
public void SendNotification(string recipient, string message)
{
emailSender.SendEmail(recipient, message);
smsSender.SendSMS(recipient, message);
}
}
In the above code, the NotificationService class depends directly on the EmailSender and SMSSender classes, violating the Dependency Inversion Principle. This makes it difficult to change the implementation of the EmailSender and SMSSender classes or add new types of senders without modifying the NotificationService class.
To adhere to the Dependency Inversion Principle, we can introduce an abstraction layer between the NotificationService class and the sender classes. We can create an interface called INotificationSender that both EmailSender and SMSSender classes will implement:
public interface INotificationSender
{
void SendNotification(string recipient, string message);
}
public class EmailSender : INotificationSender
{
public void SendNotification(string recipient, string message)
{
// Implementation for sending email
}
}
public class SMSSender : INotificationSender
{
public void SendNotification(string recipient, string message)
{
// Implementation for sending SMS
}
}
In the above code, both EmailSender and SMSSender classes now implement the INotificationSender interface.
Now, we can modify the NotificationService class to depend on the INotificationSender interface instead of the concrete sender classes:
public class NotificationService
{
private INotificationSender emailSender;
private INotificationSender smsSender;
public NotificationService(INotificationSender emailSender, INotificationSender smsSender)
{
this.emailSender = emailSender;
this.smsSender = smsSender;
}
public void SendNotification(string recipient, string message)
{
emailSender.SendNotification(recipient, message);
smsSender.SendNotification(recipient, message);
}
}
In the above code, the NotificationService class now depends on the INotificationSender interface instead of the concrete sender classes. This makes it easier to change the implementation of the sender classes or add new types of senders without modifying the NotificationService class. It also makes the code more flexible and easier to test.
Conclusion
Design patterns are an essential part of software development. They provide proven solutions to common software design problems and promote code reusability, maintainability, scalability, efficiency, and collaboration. By following established design patterns, developers can focus on implementing business logic rather than worrying about low-level details. Ultimately, the use of design patterns can result in higher-quality code that is easier to maintain and extend over time.
Comments