Showing posts with label LLD. Show all posts
Showing posts with label LLD. Show all posts

SOLID Principles Implementation in C#.

Software development is not just about writing code; it’s about writing clean, maintainable, scalable, and testable code. That’s exactly what the SOLID principles help us achieve.

Whether you’re building a small API or an enterprise-level microservice, understanding SOLID principles makes your code more flexible, easier to extend, and less prone to bugs.

In this article, we’ll break down SOLID principles with simple explanations and practical C# implementations.

SOLID Principle Full Form

What is the SOLID Principle?

The SOLID Principles are a set of five core object-oriented design (OOD) guidelines used in software development to create systems that are more understandable, flexible, maintainable, and scalable. Coined by Michael Feathers and based on principles from Robert C. Martin ("Uncle Bob").

SOLID is an acronym for five object-oriented design principles: 

  • S: Single Responsibility Principle 
  • O: Open/Closed Principle 
  • L: Liskov Substitution Principle 
  • I: Interface Segregation Principle 
  • D: Dependency Inversion Principle 

Let’s go through each principle one by one and look at the problems that arise when we don’t follow the SOLID Principle. After that, we’ll explore how applying these principles can help us write cleaner, more maintainable code.

1. S - Single Responsibility Principle.

The 'S' in the SOLID principles stands for the Single Responsibility Principle (SRP). The Single Responsibility Principle states that a class, module, or function should have only one reason to change, meaning it should have a single, clearly defined purpose or job within the software system.

Bad Example: Imagine an online shopping system where:

  • An order is created
  • Order is saved to the database
  • Customer is emailed

If all of this is in ONE class, it violates SRP.

Example Code:

public class OrderService
{
    public void CreateOrder()
    {
        Console.WriteLine("Order created.");
    }

    public void SaveToDatabase()
    {
        Console.WriteLine("Order saved to DB.");
    }

    public void SendEmail()
    {
        Console.WriteLine("Email sent to customer.");
    }
}

Let's see how we can write better code using SRP.

Good Example: To follow the single responsibility principle, we have created classes in such a way that each class has its own responsibility and reason to change. In our example, we will split the responsibilities into three different classes.

Example Code:
namespace PracticeCode.SOLID
{
    public class OrderCreator
    {
        public void CreateOrder()
        {
            Console.WriteLine("Order created.");
        }
    }

    public class OrderRepository
    {
        public void Save()
        {
            Console.WriteLine("Order saved to DB.");
        }
    }

    public class EmailService
    {
        public void Send()
        {
            Console.WriteLine("Email sent to customer.");
        }
    }

    public class OrderService
    {
        private readonly OrderCreator _orderCreator;
        private readonly OrderRepository _orderRepository;
        private readonly EmailService _emailService;

        public OrderService()
        {
            _orderCreator = new OrderCreator();
            _orderRepository = new OrderRepository();
            _emailService = new EmailService();
        }

        public void ProcessOrder()
        {
            _orderCreator.CreateOrder();
            _orderRepository.Save();
            _emailService.Send();
        }
    }
}
Program.cs
OrderService processor = new OrderService();
processor.ProcessOrder();
Console.ReadLine();
Output:
Order created.
Order saved to DB.
Email sent to customer.

Now each class has only one reason to change. This is the correct example of SRP.

2. O - Open/Closed Principle (OCP)

A class should open for extension but close for modification. It means that you should be able to add new functionality without changing the existing code. This avoids breaking old logic when adding new features.

Bad Example: Multiple types of discount calculations are implemented in a single class. If you want to add a new discount type, you need to modify the same class repeatedly. This violates OCP because modification is required for every new discount.
public class DiscountService
{
    public decimal GetDiscount(string customerType, decimal amount)
    {
        if (customerType == "Regular")
            return amount * 0.05m;

        if (customerType == "Premium")
            return amount * 0.10m;

        return 0;
    }
}

Good Example: We can refactor the above code using Inheritance and Polymorphism. Let's see how we can use the open/close principle to write better code.

Step 1: Create a base class, also called an extension point.
public abstract class Discount
{
    public abstract decimal Calculate(decimal amount);
}

Step 2: Create separate classes for each discount type.
public class RegularDiscount : Discount
{
    public override decimal Calculate(decimal amount)
        => amount * 0.05m;
}

public class PremiumDiscount : Discount
{
    public override decimal Calculate(decimal amount)
        => amount * 0.10m;
}
You can add a new type like:
public class GoldDiscount : Discount
{
    public override decimal Calculate(decimal amount)
        => amount * 0.15m;
}

Step 3: Create a Factory Class to choose the correct discount type.
//----------------Discount Factory----------------//
public static class DiscountFactory
{
    public static Discount GetDiscount(string discountType)
    {
        return discountType.ToLower() switch
        {
            "regular" => new RegularDiscount(),
            "seasonal" => new SeasonalDiscount(),
            _ => throw new ArgumentException("Invalid discount type"),
        };
    }
}

public class Customer
{
    private readonly Discount _discount;

    public Customer(Discount discount)
    {
        _discount = discount;
    }

    public decimal GetFinalAmount(decimal amount)
    {
        return _discount.ApplyDiscount(amount);
    }
}

Step 4: Calling the Factory Method in the Program.cs
class Program
{
    static void Main(string[] args)
    {
        Console.Write("Enter discount type (regular / seasonal): ");
        string type = Console.ReadLine();

        Discount discount = DiscountFactory.CreateDiscount(type);

        Customer customer = new Customer(discount);

        decimal amount = 1000;
        decimal finalAmount = customer.GetFinalAmount(amount);

        Console.WriteLine($"Final amount for {type} discount: {finalAmount}");
    }
}
Output:
Enter discount type (regular / seasonal): seasonal
Final amount for seasonal discount: 800

Now our old code will never change, even if we want to implement any new discount type in the future. This is what the open/closed principle wants.

3. L - Liskov Substitution Principle (LSP)

A child class should be replaceable for its parent class without breaking the program. It means wherever you use a base class, you should be able to use its derived class without causing errors or unexpected behavior. If substituting a child class changes the behavior, it violates LSP.

Bad Example: If the Payment class has Refund(), a CashPayment class that throws an exception for Refund violates LSP.

Example Code: We have a base class with Pay and Refund options that can be overridden by derived classes.
public class Payment
{
    public virtual void Pay(decimal amount)
    {
        Console.WriteLine($"Payment of {amount} done.");
    }

    public virtual void Refund(decimal amount)
    {
        Console.WriteLine($"Refund of {amount} processed.");
    }
}

Now, when we create the CashPayment and CardPayment derived classes, we inherit from the Payment class. CardPayment works fine because it has both Pay and Refund options, but CashPayment does not have a refund option.
public class CashPayment : Payment
{
    public override void Refund(decimal amount)
    {
        throw new NotSupportedException("Cash cannot be refunded!");
    }
}

Here, it will throw a runtime error when you create a child object and assign it to the Parent, because the child class is not substitutable for the Parent class.
Payment payment = new CashPayment();
payment.Refund(100);   // 💥 Crash at runtime!

CashPayment violates LSP because it cannot safely replace Payment’s refund behavior, causing the program to break when substituted.

Good Example: We FIX this by splitting responsibilities using interfaces.
We are splitting the Payment system into two interfaces, IPayment and IRfundable, and different methods inherit and implement them based on their functionality.
namespace PracticeCode.SOLID
{
    public interface IPayment
    {
        void Pay(decimal amount);
    }
    public interface IRfundable
    {
        void Refund(decimal amount);
    }

    public class CardPayment: IPayment, IRfundable
    {
        public void Pay(decimal amount)
            => Console.WriteLine($"Card Amount Paid: {amount}");

        public void Refund(decimal amount)
            => Console.WriteLine($"Card Amount Refund: {amount}");
    }

    public class CashPayment: IPayment
    {
        public void Pay(decimal amount)
            => Console.WriteLine($"Cash Amount Paid: {amount}");
    }

    public class PaymentProcess
    {
        public void ProcessPayment(IPayment payment, decimal amount)
        {
            payment.Pay(amount);
        }
        public void RefundPayment(IRfundable refund, decimal amount)
        {
            refund.Refund(amount);
        }
    }
}

We will create a PaymentProcess object and call the methods defined inside with a child class object. As there is no Refund for Cash payment, we will get a compile-time error when we try to call the refund method (Program.cs).
//LSP
var processPayment = new PaymentProcess();
processPayment.ProcessPayment(new CardPayment(), 100);
processPayment.ProcessPayment(new CashPayment(), 100);
processPayment.RefundPayment(new CardPayment(), 100);
//processPayment.RefundPayment(new CashPayment(), 100); //Compile time error

This is how we can easily replace a child class object with a parent class without breaking the application by following LSP.

4. I - Interface Segregation Principle (ISP)

A class should not be forced to implement methods it does not use. Instead of one large interface, create many smaller interfaces. This keeps code clean, avoids empty methods, and removes unnecessary dependencies.

Bad Example: Having a big IWorker interface with Work() and Eat() forces robots to implement Eat(), which they don’t need.
public interface IWorker
{
    void Work();
    void Eat();
}

public class Robot : IWorker
{
    public void Work() 
    {
        Console.WriteLine("Robot working.");
    }

    public void Eat() 
    {
        throw new NotImplementedException("Robots don't eat!");
    }
}

Robots do NOT eat, but are forced to implement Eat(). This violates ISP because the interface forces unnecessary methods.

Good Example: Instead, splitting interfaces into IWorkable and IFeedable keeps everything clean and avoids unnecessary code.
public interface IWorkable
{
    void Work();
}

public interface IFeedable
{
    void Eat();
}

// Now classes implement only what they need
public class Human : IWorkable, IFeedable
{
    public void Work() => Console.WriteLine("Human working.");
    public void Eat()  => Console.WriteLine("Human eating.");
}

public class Robot : IWorkable
{
    public void Work() => Console.WriteLine("Robot working.");
}

Now use them in the Program.cs
namespace PracticeCode.SOLID
{
    class Program
    {
        static void Main(string[] args)
        {
            // Using IWorkable interface
            IWorkable humanWorker = new Human();
            IWorkable robotWorker = new Robot();

            humanWorker.Work();   // Output: Human working.
            robotWorker.Work();   // Output: Robot working.

            Console.WriteLine();

            // Using IFeedable interface
            IFeedable humanEater = new Human();
            humanEater.Eat();     // Output: Human eating.

            // Robot does NOT implement IFeedable
            // So you cannot do this:
            // IFeedable robotEater = new Robot();  // ❌ Compile-time error (Good!)

            Console.ReadLine();
        }
    }
}

Human implements both interfaces; Robot only implements IWorkable.

5. D - Dependency Inversion Principle (DIP)

High-level modules should not depend on low-level modules. Both should depend on abstractions. This makes your code flexible, testable, maintainable, and loosely coupled.

Bad Example: NotificationService depends directly on EmailService

public class EmailService
{
    public void Send(string message)
    {
        Console.WriteLine("EMAIL: " + message);
    }
}

public class NotificationService
{
    private readonly EmailService _emailService = new EmailService(); // ❌ Hard-coded dependency

    public void Notify(string message)
    {
        _emailService.Send(message);
    }
}

Because NotificationService directly depends on EmailService, it becomes tightly coupled, hard to extend, hard to test, and breaks when EmailService changes, clearly violating DIP.

Good Example: Instead of NotificationService calling EmailService directly,
We depend on IMessageService so we can switch between Email, SMS, or WhatsApp without modifying NotificationService.

Step 1: Create an abstraction (Interface) and implement Email & SMS services.
public interface IMessageService
{
    void Send(string message);
}

public class EmailService : IMessageService
{
    public void Send(string message)
    {
        Console.WriteLine("EMAIL: " + message);
    }
}

public class SmsService : IMessageService
{
    public void Send(string message)
    {
        Console.WriteLine("SMS: " + message);
    }
}

Step 2: High-level class depends on abstraction.
public class NotificationService
{
    private readonly IMessageService _messageService;

    public NotificationService(IMessageService messageService)
    {
        _messageService = messageService;
    }

    public void Notify(string message)
    {
        _messageService.Send(message);
    }
}

Step 3: Use it in the Program.cs
class Program
{
    static void Main(string[] args)
    {
        IMessageService service = new SmsService();     // choose service
        NotificationService notifier = new NotificationService(service);

        notifier.Notify("Order shipped!");               // SMS: Order shipped!

        Console.ReadLine();
    }
}

I hope you are now clear about the real-life use case of SOLID principles. By applying SOLID correctly in your C# projects, you make your architecture:

  • Scalable
  • Maintainable
  • Testable
  • Professional

If you're preparing for interviews, mastering SOLID is one of the biggest advantages you can have.

DON'T MISS

Tech News
© all rights reserved
made with by AlgoLesson