Solid Principles In Depth

Date
Authors

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:

  1. Single Responsibility Principle
  2. Open Closed Principle
  3. Liskov Principle
  4. Interface Segregation Principle
  5. 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.