11
OctSOLID Design Principles In C# Explained
SOLID Principles Explained Using C#
SOLID principles are essential concepts in object-oriented programming and design, particularly in C#. These principles help developers create better manageable, intelligible, and flexible software systems. By following SOLID principles, you may improve code quality while lowering the risk of introducing errors.
In this Design Patterns tutorial, we will discuss the SOLID Principles in C#, which include the following: What are the SOLID Principles?,and when should they be used? We'll also go over instances of each SOLID principle in C#. So, let us begin by examining what SOLID Principles are.
Why Do We Need to Learn SOLID Design Principles?
- As developers, we frequently begin designing applications with our previous expertise and knowledge.
- However, in time, these apps may grow prone to bugs.
- Each change request or new feature could involve changes to the app's appearance.
- Even simple activities may demand tremendous effort and an overall understanding of the system.
- It is critical to understand that change requests and new features are inherent in software development; they cannot be denied.
- The actual issue is with the application's design. Poor design might make the system more complex and challenging to maintain and update.
To address the issues raised above and to help students, beginners, and professional software developers learn how to create robust software using SOLID principles in C#, I've chosen to launch a series of articles on SOLID creation Principles. These articles will offer real-world examples to help readers understand the material and learn quickly.
Read More: |
Different Types of Design Patterns |
Different Types of Software Design Principles |
What are the main reasons for most unsuccessful applications?
The following are the fundamental causes for most Unsuccessful applications
- Overloading Classes with Functionalities: We frequently add too many functionalities to a class, even if they are not linked. This creates large and ungainly courses that are difficult to manage.
- Tight Coupling Between Software Components: When classes are closely connected and rely significantly on one another, changes to one class can have a cascading effect, affecting other classes and making the system fragile and challenging to adjust.
How to Avoid Unsuccessful Application Development Problems?
To achieve successful application development, we must consider the following critical factors:
- Use the Appropriate Architecture: Choose the appropriate architectural pattern (e.g., MVC, Layered, 3-tier, MVP) that best meets the project's requirements. This decision has far-reaching implications for the application's scalability, maintainability, and structure.
- Follow Design concepts: Stick to recognized design concepts like SOLID and ONION. These principles lay the groundwork for building clean, maintainable, and scalable code.
- Choose the Right Design Patterns: Use the design patterns that are suited to the project's requirements. These include creational patterns (e.g., factory, singleton), structural patterns (e.g., adapter, composite), behavioral patterns (e.g., observer, strategy), dependency injection, and repository patterns. Correct application of these patterns can solve common design issues and improve code quality.
What are solid principles?
- The SOLID design principles serve as the framework for dealing with common software design difficulties that arise in daily development.
- These concepts have been demonstrated to be effective in developing software that is clear, versatile, and maintained.
Let’s discuss each principle with code examples.
1. Single Responsibility Principle (SRP)
- In C#, the Single Responsibility Principle (SRP) suggests that a class should only have one cause to change, i.e., one task or responsibility.
- This idea makes code more maintainable, intelligible, and versatile by requiring that each class handle a single functionality or problem.
Example
Before Applying SRP
using System;
using System.IO;
public class Employee
{
public string Name { get; set; }
public int HoursWorked { get; set; }
public decimal HourlyRate { get; set; }
public void SaveToFile()
{
File.WriteAllText("employee.txt", $"Name: {Name}, Hours Worked: {HoursWorked}, Hourly Rate: {HourlyRate}");
Console.WriteLine("Employee data saved to file.");
}
public decimal CalculateSalary()
{
return HoursWorked * HourlyRate;
}
}
class Program
{
static void Main(string[] args)
{
Employee employee = new Employee { Name = "John", HoursWorked = 40, HourlyRate = 20 };
employee.SaveToFile(); // Violates SRP
Console.WriteLine("Salary: " + employee.CalculateSalary()); // Violates SRP
}
}
Output
Employee data saved to file.
Salary: 800
Explanation
In this example, the Employee class is responsible for both saving the employee details to a file and calculating the salary, violating the SRP.
After Applying SRP
using System;
using System.IO;
// Class responsible for storing employee data
public class Employee
{
public string Name { get; set; }
public int HoursWorked { get; set; }
public decimal HourlyRate { get; set; }
}
// Class responsible for saving employee data
public class EmployeeDataSaver
{
public void SaveToFile(Employee employee)
{
File.WriteAllText("employee.txt", $"Name: {employee.Name}, Hours Worked: {employee.HoursWorked}, Hourly Rate: {employee.HourlyRate}");
Console.WriteLine("Employee data saved to file.");
}
}
// Class responsible for salary calculations
public class SalaryCalculator
{
public decimal CalculateSalary(Employee employee)
{
return employee.HoursWorked * employee.HourlyRate;
}
}
class Program
{
static void Main(string[] args)
{
Employee employee = new Employee { Name = "John", HoursWorked = 40, HourlyRate = 20 };
// Saving employee data to file using a dedicated class
EmployeeDataSaver dataSaver = new EmployeeDataSaver();
dataSaver.SaveToFile(employee);
// Calculating salary using a dedicated class
SalaryCalculator salaryCalculator = new SalaryCalculator();
Console.WriteLine("Salary: " + salaryCalculator.CalculateSalary(employee));
}
}
Output
Employee data saved to file.
Salary: 800
Explanation
In this example,
- The "Employee" class only stores employee data.
- "EmployeeDataSaver"is responsible for saving the data and adhering to SRP.
- "SalaryCalculator" is responsible for calculating the salary.
2. Open Closed Principle (OCP)
- The Open/Closed Principle (OCP) of C# specifies that a class should be available for extension but closed for modification.
- This means that you should be able to add new functionality or behavior to a class without changing the old code, which is often accomplished through interfaces, abstract classes, and inheritance.
- OCP helps to construct maintainable and scalable systems by limiting changes to current code when new features are implemented.
Example
Before applying for OCP
using System;
public class Rectangle
{
public double Width { get; set; }
public double Height { get; set; }
}
public class Circle
{
public double Radius { get; set; }
}
public class Shape
{
public double CalculateArea(object shape)
{
if (shape is Rectangle rectangle)
{
return rectangle.Width * rectangle.Height;
}
else if (shape is Circle circle)
{
return Math.PI * circle.Radius * circle.Radius;
}
throw new ArgumentException("Unknown shape!");
}
}
class Program
{
static void Main(string[] args)
{
Shape shapeCalculator = new Shape();
Rectangle rectangle = new Rectangle { Width = 5, Height = 10 };
Circle circle = new Circle { Radius = 3 };
Console.WriteLine("Rectangle Area: " + shapeCalculator.CalculateArea(rectangle)); // Violates OCP
Console.WriteLine("Circle Area: " + shapeCalculator.CalculateArea(circle)); // Violates OCP
}
}
Output
Rectangle Area: 50
Circle Area: 28.274333882308138
Explanation
In this example,
- If you need to add a new shape (e.g., triangle), you must modify the Shape class, violating OCP.
- Every time you add new shapes, you risk breaking existing code.
After applying OCP
using System;
// Base class or interface for shapes
public abstract class Shape
{
public abstract double CalculateArea();
}
// Rectangle class extending the base class
public class Rectangle : Shape
{
public double Width { get; set; }
public double Height { get; set; }
public override double CalculateArea()
{
return Width * Height;
}
}
// Circle class extending the base class
public class Circle : Shape
{
public double Radius { get; set; }
public override double CalculateArea()
{
return Math.PI * Radius * Radius;
}
}
// Now we can add new shapes without modifying the existing code
public class Triangle : Shape
{
public double Base { get; set; }
public double Height { get; set; }
public override double CalculateArea()
{
return 0.5 * Base * Height;
}
}
class Program
{
static void Main(string[] args)
{
// Using polymorphism to handle different shapes
Shape rectangle = new Rectangle { Width = 5, Height = 10 };
Shape circle = new Circle { Radius = 3 };
Shape triangle = new Triangle { Base = 4, Height = 6 };
Console.WriteLine("Rectangle Area: " + rectangle.CalculateArea());
Console.WriteLine("Circle Area: " + circle.CalculateArea());
Console.WriteLine("Triangle Area: " + triangle.CalculateArea());
}
}
Output
Rectangle Area: 50
Circle Area: 28.274333882308138
Triangle Area: 12
Explanation
In this example,
- Shape is an abstract base class that defines the CalculateArea method.
- Rectangle, Circle, and Triangle classes extend the base class and provide their own implementations of the CalculateArea method.
- You can add more shapes in the future by simply creating a new class (e.g., Triangle) without modifying the existing classes.
Read More: |
Implementation of Dependency Injection In C# |
.Net Design Patterns Interview Questions, You Must Know! |
3. Liskov Substitution Principle (LSP)
- The Liskov Substitution Principle (LSP) argues that objects of a superclass should be interchangeable with objects of a subclass without affecting the program's validity.
- In other words, subclasses must increase the functionality of the parent class while maintaining its behavior, guaranteeing that derived classes can be used interchangeably with their base class without introducing unanticipated problems.
- This idea contributes to a consistent and trustworthy interface.
Example
Before applying LSP
using System;
public class Bird
{
public virtual void Fly()
{
Console.WriteLine("The bird is flying.");
}
}
public class Penguin : Bird
{
// Penguin cannot fly, so overriding Fly method with wrong behavior
public override void Fly()
{
throw new NotSupportedException("Penguins can't fly.");
}
}
class Program
{
static void Main(string[] args)
{
Bird myBird = new Bird();
myBird.Fly(); // Works fine
Bird myPenguin = new Penguin();
myPenguin.Fly(); // Violates LSP, throws an exception
}
}
Output
Please pass correct input parameters!
Explanation
In this example, we have a Bird class where all birds are expected to fly. However, the Penguin class throws an exception in the Fly() method because penguins can't fly, violating LSP.
After applying LSP
using System;
// Base class for all birds
public abstract class Bird
{
public abstract void Move();
}
// Derived class for flying birds
public class FlyingBird : Bird
{
public override void Move()
{
Fly();
}
public virtual void Fly()
{
Console.WriteLine("This bird is flying.");
}
}
// Derived class for non-flying birds
public class NonFlyingBird : Bird
{
public override void Move()
{
Walk();
}
public virtual void Walk()
{
Console.WriteLine("This bird is walking.");
}
}
// Subclass for sparrow, a flying bird
public class Sparrow : FlyingBird
{
public override void Fly()
{
Console.WriteLine("The sparrow is flying.");
}
}
// Subclass for penguin, a non-flying bird
public class Penguin : NonFlyingBird
{
public override void Walk()
{
Console.WriteLine("The penguin is waddling.");
}
}
class Program
{
static void Main(string[] args)
{
// Now substitution works correctly
Bird sparrow = new Sparrow();
sparrow.Move(); // Output: "The sparrow is flying."
Bird penguin = new Penguin();
penguin.Move(); // Output: "The penguin is waddling."
}
}
Output
The sparrow is flying.
The penguin is waddling.
Explanation
In this example,
- We have an abstract Bird class that defines a general behavior (Move()), which is then specialized in subclasses.
- FlyingBird and NonFlyingBird are derived from Bird. Each subclass handles its own behavior: FlyingBird uses Fly(), and NonFlyingBird uses Walk().
- The substitution now works correctly: you can replace Bird with either Sparrow or Penguin, and they behave as expected, adhering to LSP.
4. Interface Segregation Principle (ISP)
- The Interface Segregation Principle (ISP) argues that no client should be compelled to rely on methods that it does not use.
- This entails creating small, particular interfaces rather than one huge, general-purpose interface.
- ISP contributes to a more modular and maintainable codebase in which clients only implement the capabilities they require.
Example
Before applying ISP
using System;
// Interface with methods not all workers need
public interface IWorker
{
void Work();
void Eat(); // Not all workers eat
}
public class Human : IWorker
{
public void Work()
{
Console.WriteLine("Human is working.");
}
public void Eat()
{
Console.WriteLine("Human is eating.");
}
}
public class Robot : IWorker
{
public void Work()
{
Console.WriteLine("Robot is working.");
}
// Robot doesn't eat, but we are forced to implement Eat() anyway
public void Eat()
{
throw new NotImplementedException("Robots don't eat.");
}
}
class Program
{
static void Main(string[] args)
{
IWorker human = new Human();
human.Work(); // Output: Human is working.
human.Eat(); // Output: Human is eating.
IWorker robot = new Robot();
robot.Work(); // Output: Robot is working.
robot.Eat(); // Throws exception: NotImplementedException: Robots don't eat.
}
}
After applying ISP
using System;
// Separate interface for working
public interface IWorkable
{
void Work();
}
// Separate interface for eating
public interface IEatable
{
void Eat();
}
// Humans can work and eat
public class Human : IWorkable, IEatable
{
public void Work()
{
Console.WriteLine("Human is working.");
}
public void Eat()
{
Console.WriteLine("Human is eating.");
}
}
// Robots only work, they don't eat
public class Robot : IWorkable
{
public void Work()
{
Console.WriteLine("Robot is working.");
}
}
class Program
{
static void Main(string[] args)
{
// Human can work and eat
IWorkable humanWorker = new Human();
humanWorker.Work(); // Output: Human is working.
IEatable humanEater = new Human();
humanEater.Eat(); // Output: Human is eating.
// Robot can only work, no need for Eat() method
IWorkable robotWorker = new Robot();
robotWorker.Work(); // Output: Robot is working.
}
}
Output
Human is working.
Human is eating.
Robot is working.
Explanation
In this example,
- We now have two separate interfaces: IWorkable for the Work() method and IEatable for the Eat() method.
- The Human class implements both IWorkable and IEatable because humans can both work and eat.
- The Robot class implements only IWorkable because robots can only work, but they don’t eat.
5. Dependency Inversion Principle (DIP)
- The Dependency Inversion Principle (DIP) suggests that high-level modules should not rely on low-level modules but on abstractions.
- Furthermore, abstractions should not be dependent on details but rather the reverse.
- This principle encourages decoupling by ensuring that code is based on interfaces or abstract classes, which improves flexibility and maintenance.
Example
Before applying DIP
using System;
public class EmailService
{
public void SendEmail(string message)
{
Console.WriteLine("Sending email: " + message);
}
}
public class UserService
{
private readonly EmailService _emailService;
public UserService()
{
_emailService = new EmailService();
}
public void NotifyUser(string message)
{
_emailService.SendEmail(message);
}
}
class Program
{
static void Main(string[] args)
{
UserService userService = new UserService();
userService.NotifyUser("Hello, user!"); // Sends email: Hello, user!
}
}
Output
Sends email: Hello, user!
Explanation
In this example,
- A UserService directly depends on the EmailService. If we want to change the way notifications are sent (e.g., via SMS), we'd need to modify the UserService class, which violates the Dependency Inversion Principle.
After applying DIP
using System;
// Abstraction (interface) for sending notifications
public interface INotificationService
{
void Send(string message);
}
// Concrete implementation: EmailService
public class EmailService : INotificationService
{
public void Send(string message)
{
Console.WriteLine("Sending email: " + message);
}
}
// Concrete implementation: SMSService
public class SMSService : INotificationService
{
public void Send(string message)
{
Console.WriteLine("Sending SMS: " + message);
}
}
// High-level module (UserService) depends on abstraction (INotificationService)
public class UserService
{
private readonly INotificationService _notificationService;
// Dependency injection via constructor
public UserService(INotificationService notificationService)
{
_notificationService = notificationService;
}
public void NotifyUser(string message)
{
_notificationService.Send(message);
}
}
class Program
{
static void Main(string[] args)
{
// Using EmailService
INotificationService emailService = new EmailService();
UserService emailUserService = new UserService(emailService);
emailUserService.NotifyUser("Hello via Email!"); // Output: Sending email: Hello via Email!
// Using SMSService
INotificationService smsService = new SMSService();
UserService smsUserService = new UserService(smsService);
smsUserService.NotifyUser("Hello via SMS!"); // Output: Sending SMS: Hello via SMS!
}
}
Output
Sending email: Hello via Email!
Sending SMS: Hello via SMS!
Explanation
In this example,
- INotificationService: Defines Send() method, implemented by EmailService and SMSService.
- UserService: Depends on INotificationService to send notifications, allowing flexibility.
- Program: Uses EmailService and SMSService with UserService to send messages via email or SMS.
Read More: |
Top 50 Java Design Patterns Interview Questions and Answers |
Most Frequently Asked Software Architect Interview Questions and Answers |