SOLID Principles
These principles guide object-oriented design for more understandable, flexible, and maintainable software. For example, the Single Responsibility Principle (SRP) suggests a class should have only one reason to change, such as a User class handling user properties but not user persistence, which is delegated to a separate UserRepository class
Solid principles guide object-oriented design and programming for more maintainable, understandable, and flexible software.
Here are the principles with simple examples:
Single Responsibility Principle (SRP)
A class should have one, and only one, reason to change. Example: A User class should handle user properties but not user data storage, which should be handled by a UserRepository class.
Open/Closed Principle (OCP)
Software entities should be open for extension but closed for modification. Example: Implementing a Shape interface allows adding new shapes without altering the logic of a ShapeDrawer class.
Liskov Substitution Principle (LSP)
Objects of a superclass should be replaceable with objects of subclasses without affecting the correctness of the program. Example: A Bird class with a fly method, where subclasses like Duck can fly, but a Penguin subclass should not implement fly as it cannot fly, indicating a need to rethink the class hierarchy.
Interface Segregation Principle (ISP)
Many client-specific interfaces are better than one general-purpose interface. Example: Instead of one large IWorker interface, have IWork and IEat interfaces for HumanWorker and RobotWorker classes, where RobotWorker only implements IWork.
Dependency Inversion Principle (DIP)
High-level modules should not depend on low-level modules. Both should depend on abstractions. Example: A BookReader class should depend on an IBook interface, not directly on a concrete PDFBook or EpubBook class, allowing for easy extension to new book formats.
These principles help in designing systems that are easier to test, maintain, and extend.
Single Responsibility Principle (SRP)
// Violates SRP
public class UserManager {
public void AddUser(string username) {
// Add user to database
}
public void SendEmail(string emailContent) {
// Send an email to the user
}
}
// Follows SRP
public class User {
public void AddUser(string username) {
// Add user to database
}
}
public class EmailService {
public void SendEmail(string emailContent) {
// Send an email
}
}
Open/Closed Principle (OCP)
public abstract class Shape {
public abstract double Area();
}
public class Circle : Shape {
public double Radius { get; set; }
public override double Area() => Math.PI * Math.Pow(Radius, 2);
}
public class Square : Shape {
public double Length { get; set; }
public override double Area() => Math.Pow(Length, 2);
}
Liskov Substitution Principle (LSP)
public class Bird {
public virtual void Fly() { }
}
public class Sparrow : Bird {
public override void Fly() {
// Implementation for flying
}
}
public class Ostrich : Bird {
public override void Fly() {
throw new NotImplementedException("Ostriches cannot fly.");
}
}
Interface Segregation Principle (ISP)
public interface IWorker {
void Work();
}
public interface IFeeder {
void Eat();
}
public class HumanWorker : IWorker, IFeeder {
public void Work() {
// Working
}
public void Eat() {
// Eating
}
}
public class RobotWorker : IWorker {
public void Work() {
// Working
}
}
Dependency Inversion Principle (DIP)
public interface IMessageSender {
void SendMessage(string message);
}
public class EmailSender : IMessageSender {
public void SendMessage(string message) {
// Send email
}
}
public class Notification {
private IMessageSender _messageSender;
public Notification(IMessageSender messageSender) {
_messageSender = messageSender;
}
public void Send(string message) {
_messageSender.SendMessage(message);
}
}
The Dependency Inversion Principle (DIP) is about reducing the dependency between high-level modules (which provide complex logic) and low-level modules (which provide utility operations) by introducing an abstraction layer. Instead of high-level modules depending on the low-level modules, both should depend on abstractions. In the example provided, Notification is a high-level module that doesn't directly instantiate an EmailSender (a low-level module). Instead, it interacts with an IMessageSender interface—an abstraction. This way, Notification can work with any form of message sending, be it email, SMS, or any other method, as long as the sender implements IMessageSender. This principle allows for easier maintenance and flexibility, as changes in the way messages are sent (like switching from email to SMS) don't require changes to the Notification class.
The connection between IMessageSender and EmailSender is made through dependency injection, typically outside of the Notification class. Here's a simplified workflow:
- Define the Interface and Concrete Implementation: IMessageSender is the interface, and EmailSender is a concrete implementation that adheres to this interface.
- Inject the Dependency: When creating an instance of Notification, you inject a concrete implementation of IMessageSender (like EmailSender) into it. This is often done via a constructor, as shown in the example.
- Use the Injected Service: The Notification class uses _messageSender to send messages. Since _messageSender is of type IMessageSender, it can use any object that implements this interface, allowing for flexibility.
This approach decouples the Notification class from the specific message-sending mechanism. The actual connection between IMessageSender and EmailSender is made at runtime, typically through an inversion of control (IoC) container, which handles the instantiation and injection of dependencies based on the configured mappings between interfaces and their concrete implementations.
With both EmailSender and SmsSender implementing IMessageSender, you can choose at runtime which implementation to use for sending messages. This decision can be based on configuration, user input, or other logic within your application. When instantiating the Notification class, you inject either an EmailSender or SmsSender object as the IMessageSender dependency. This flexibility allows your Notification class to remain unchanged while supporting multiple messaging strategies, demonstrating the power of dependency injection and the Dependency Inversion Principle in enabling scalable and maintainable software design.
Here's how you can implement the Dependency Inversion Principle with both EmailSender and SmsSender
public interface IMessageSender {
void SendMessage(string message);
}
public class EmailSender : IMessageSender {
public void SendMessage(string message) {
Console.WriteLine($"Sending Email: {message}");
}
}
public class SmsSender : IMessageSender {
public void SendMessage(string message) {
Console.WriteLine($"Sending SMS: {message}");
}
}
public class Notification {
private IMessageSender _messageSender;
public Notification(IMessageSender messageSender) {
_messageSender = messageSender;
}
public void Send(string message) {
_messageSender.SendMessage(message);
}
}
// Usage
class Program {
static void Main(string[] args) {
IMessageSender emailSender = new EmailSender();
IMessageSender smsSender = new SmsSender();
Notification emailNotification = new Notification(emailSender);
emailNotification.Send("Hello via Email");
Notification smsNotification = new Notification(smsSender);
smsNotification.Send("Hello via SMS");
}
}
In this setup, Notification can send messages via email or SMS, depending on which IMessageSender implementation is injected at runtime. This design adheres to the Dependency Inversion Principle by depending on an abstraction (IMessageSender) rather than concrete classes (EmailSender or SmsSender).
These examples are simplified to demonstrate the principles. Real-world applications might require more detailed implementations considering context and requirements.