Solid Principles In Depth
- Date
- Authors
- Name
- Mehdi Hadeli
- @mehdihadeli
Introduction
The SOLID consist of five design principle in object oriented software development that help developers to write code that is more maintainable, scalable, and reusable.
The SOLID principles are:
- Single Responsibility Principle
- Open Closed Principle
- Liskov Principle
- Interface Segregation Principle
- Dependency Inversion Principle
In this blog we will discuss each one of principles in details and also provide an example for each principle that in the first this example violated the SOLID principles, and we will refactor it to the SOLID version to have a maintainable code base.
Single Responsibility Principle
A class should have only one reason to change. It means that each class should have only one responsibility.
Violated Example
In this example the CreateUserCommandHandler
class below handles three separate responsibility validation,database persistence, and sending email in one class.
public class CreateUserCommandHandler
{
public void Handle(CreateUserCommand command)
{
// 1. Validation
if (string.IsNullOrEmpty(command.Email))
{
throw new ArgumentException("Email cannot be empty");
}
if (command.Password.Length < 8)
{
throw new ArgumentException("Password must be at least 8 characters");
}
// 2. Persistence
var user = new User
{
Email = command.Email,
PasswordHash = HashPassword(command.Password)
};
using (var context = new AppDbContext())
{
context.Users.Add(user);
context.SaveChanges();
}
// 3. Email sending
var emailClient = new SmtpClient("smtp.example.com");
var mailMessage = new MailMessage
{
From = new MailAddress("[email protected]"),
Subject = "Welcome to our platform",
Body = "Thank you for registering!"
};
mailMessage.To.Add(command.Email);
emailClient.Send(mailMessage);
}
private string HashPassword(string password)
{
// Password hashing logic
}
}
Refactored Example
In the refactor version, we splited each responsibility into separate interfaces and classes:
public class CreateUserCommandHandler
{
private readonly IUserValidator _validator;
private readonly IUserRepository _repository;
private readonly IEmailSender _emailSender;
public CreateUserCommandHandler(
IUserValidator validator,
IUserRepository repository,
IEmailSender emailSender)
{
_validator = validator;
_repository = repository;
_emailSender = emailSender;
}
public void Handle(CreateUserCommand command)
{
_validator.Validate(command);
var user = new User
{
Email = command.Email,
PasswordHash = command.Password
};
_repository.AddUser(user);
_emailSender.SendWelcomeEmail(command.Email);
}
}
public interface IUserValidator
{
void Validate(CreateUserCommand command);
}
public interface IUserRepository
{
void AddUser(User user);
}
public interface IEmailSender
{
void SendWelcomeEmail(string email);
}
public class UserValidator : IUserValidator
{
public void Validate(CreateUserCommand command)
{
if (string.IsNullOrEmpty(command.Email))
{
throw new ArgumentException("Email cannot be empty");
}
if (command.Password.Length < 8)
{
throw new ArgumentException("Password must be at least 8 characters");
}
}
}
public class UserRepository : IUserRepository
{
private readonly AppDbContext _context;
public UserRepository(AppDbContext context)
{
_context = context;
}
public void AddUser(User user)
{
_context.Users.Add(user);
_context.SaveChanges();
}
}
public class EmailSender : IEmailSender
{
public void SendWelcomeEmail(string email)
{
var emailClient = new SmtpClient("smtp.example.com");
var mailMessage = new MailMessage
{
From = new MailAddress("[email protected]"),
Subject = "Welcome to our platform",
Body = "Thank you for registering!"
};
mailMessage.To.Add(email);
emailClient.Send(mailMessage);
}
}
Open Closed Principle
Classes should be open for extension but closed for modification.
It means That we can add new functionality without touching existing code.
Violated Example
In this example with Adding a new customer type
, we need to touch our method CalculateDiscount
in class DiscountCalculator
.
public enum CustomerType {
Regular,
VIP,
SuperVIP
}
public class DiscountCalculator {
public double CalculateDiscount(CustomerType customerType) {
switch (customerType) {
case CustomerType.Regular:
return 0.1;
case CustomerType.VIP:
return 0.2;
case CustomerType.SuperVIP:
return 0.3;
default:
return 0.0;
}
}
}
Refactored Example
In the refactor version, we used Strategy Pattern to add new customer type
without touching the existing code:
public interface IDiscountCalculator
{
double CalculateDiscount(CustomerType customerType);
}
public class DiscountCalculator : IDiscountCalculator {
private IDiscountStrategiesFactory _factory;
public DiscountCalculator(IDiscountStrategiesFactory factory)
{
_factory = factory;
}
public double CalculateDiscount(CustomerType customerType) {
var strategy = _factory.GetStrategy(customerType);
return strategy.GetDiscount();
}
}
public interface IDiscountStrategy {
double GetDiscount();
}
public class RegularCustomerDiscount : IDiscountStrategy {
public double GetDiscount() => 0.1;
}
public class VIPCustomerDiscount : IDiscountStrategy {
public double GetDiscount() => 0.2;
}
public class SuperVIPCustomerDiscount : IDiscountStrategy {
public double GetDiscount() => 0.3;
}
public interface IDiscountStrategiesFactory {
IDiscountStrategy GetStrategy(CustomerType customerType);
}
public class DiscountStrategiesFactory : IDiscountStrategiesFactory {
private readonly IDictionary<CustomerType, IDiscountStrategy> _discountStrategies;
public DiscountStrategiesFactory(IDictionary<CustomerType, IDiscountStrategy> discountStrategies) {
_discountStrategies = discountStrategies;
}
public IDiscountStrategy GetStrategy(CustomerType customerType) {
if (_discountStrategies.TryGetValue(customerType, out var strategy)) {
return strategy;
}
throw new ArgumentException($"No discount strategy found for: {customerType}");
}
}
Liskov Substitution Principle
Subtypes must be substitutable for their base types without breaking the application. It means that, Subclasses should behave like their Parent classes without breaking anything.
Violated Example
In this example Ostrich
can not fly, but inherits from an interface IBird
and with having member Fly()
violated the liskov rules.
internal interface IBird {
void MakeSound();
void Fly();
void Run();
}
public class Ostrich : IBird {
public void MakeSound() => Console.WriteLine("Sound.");
public void Fly() => throw new NotImplementedException();
public void Run() => Console.WriteLine("Running...");
}
Refactored Example
In the refactored version, we separated the flying and no flying birds into different interfaces:
internal interface IBird {
void MakeSound();
void Run();
}
internal interface IFlyingBird : IBird {
void Fly();
}
public class Duck : IFlyingBird {
public void MakeSound() => Console.WriteLine("Quack!");
public void Fly() => Console.WriteLine("Flying...");
public void Run() => Console.WriteLine("Running...");
}
public class Ostrich : IBird {
public void MakeSound() => Console.WriteLine("Boom!");
public void Run() => Console.WriteLine("Running...");
}
Interface Segregation Principle
Clients shouldn’t be forced to depend on interfaces they do not use. It means that an interface shouldn’t be enforcing the classes that implement some member that doesn't need it.
Violated Example
In this example, The IEmployeeTasks
interface forces all implementers to implement some members that don't need it.
public interface IEmployeeTasks {
void CreateTask();
void AssginTask();
void WorkOnTask();
}
Refactored Example
In the refactored version, we split the interface IEmployeeTasks
into two smaller interfaces IProgrammer
and ILead
:
public interface ILead {
void CreateTask();
void AssginTask();
}
public interface IProgrammer {
void WorkOnTask();
}
Here our clients only implement some interfaces that they needed:
public class Manager : ILead {
public void CreateTask() => Console.WriteLine("Task created.");
public void AssginTask() => Console.WriteLine("Task assigned.");
}
public class Programmer : IProgrammer {
public void WorkOnTask() => Console.WriteLine("Working...");
}
public class TeamLead : ILead, IProgrammer {
public void CreateTask() => Console.WriteLine("Task created.");
public void AssginTask() => Console.WriteLine("Task assigned.");
public void WorkOnTask() => Console.WriteLine("Working on task.");
}
Dependency Inversion Principle
High level modules shouldn't depend on low level modules and should only depend on abstractions. It means that, Instead of coupling to some classes we couple to some abstractions.
Violated Example
In this example NotificationManager
class is tightly coupled to EmailService
.
public class NotificationManager {
private EmailService _emailService = new EmailService();
public void SendNotification(string message) {
_emailService.SendEmail(message);
}
}
Refactored Example
In the refactored version, we instead direct coupling to class EmailService
we coupled to interface INotificationService
:
public interface INotificationService {
void SendMessage(string message);
}
public class EmailService : INotificationService {
public void SendMessage(string message) {
// Send email
}
}
public class SmsService : INotificationService {
public void SendMessage(string message) {
// Send SMS
}
}
public class NotificationManager {
private readonly INotificationService _notificationService;
public NotificationManager(INotificationService notificationService) {
_notificationService = notificationService;
}
public void SendNotification(string message) {
_notificationService.SendMessage(message);
}
}
Conclusion
The SOLID principles are powerful rules that help us to write clean, maintainable, and scalable code. By following these rules doesn't mean our code is completely perfect, but it leads our code to be more maintainable, modular, and flexible in future changes.