DEV Community

mohamed Tayel
mohamed Tayel

Posted on

C# Clean Code: SOLID Principles

Meta Description:

Explore how to apply SOLID principles in C# using a practical example of a Customer Order System. Learn how each principle—Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion—can transform your code into a clean, maintainable, and scalable solution.

SOLID is a set of five design principles that aim to make object-oriented software easier to understand, maintain, and extend. These principles were popularized by Robert C. Martin (Uncle Bob) in his 2000 paper "Design Principles and Design Patterns," and the acronym SOLID was coined a few years later.

In this article, we will walk through each of the SOLID principles and apply them to a practical example: a Customer Order System. We'll progressively refactor the example to follow each principle, transforming it into a well-structured, maintainable piece of code.

The five SOLID principles are:

  1. Single Responsibility Principle (SRP)
  2. Open-Closed Principle (OCP)
  3. Liskov Substitution Principle (LSP)
  4. Interface Segregation Principle (ISP)
  5. Dependency Inversion Principle (DIP)

Let's dive in!


Initial Code - Before Applying SOLID

We begin with a simple class called Order that handles customer orders. It calculates the total, applies discounts, and even prints the order receipt. This class, however, violates multiple SOLID principles.

public class Order
{
    public int Id { get; set; }
    public List<OrderItem> Items { get; set; }
    public string CustomerType { get; set; } // 'Regular', 'Premium'

    public Order()
    {
        Items = new List<OrderItem>();
    }

    // Calculate order total
    public decimal GetTotal()
    {
        decimal total = 0;
        foreach (var item in Items)
        {
            total += item.Price * item.Quantity;
        }

        // Apply discount based on customer type
        if (CustomerType == "Premium")
        {
            total *= 0.9m; // 10% discount for premium customers
        }

        return total;
    }

    // Print order receipt
    public void PrintReceipt()
    {
        Console.WriteLine($"Order ID: {Id}");
        foreach (var item in Items)
        {
            Console.WriteLine($"{item.Name} - {item.Quantity} x {item.Price} = {item.Quantity * item.Price}");
        }
        Console.WriteLine($"Total: {GetTotal()}");
    }
}

public class OrderItem
{
    public string Name { get; set; }
    public decimal Price { get; set; }
    public int Quantity { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

In this example, the Order class has too many responsibilities: calculating totals, applying discounts, and printing the receipt. Let's start applying the SOLID principles to fix this.


Step 1: Single Responsibility Principle (SRP)

The Single Responsibility Principle states that a class should have only one reason to change. Our Order class is currently handling both order calculations and receipt printing, which violates SRP. To fix this, we'll split these responsibilities into two separate classes.

Refactored Code:

public class Order
{
    public int Id { get; set; }
    public List<OrderItem> Items { get; set; }
    public string CustomerType { get; set; }

    public Order()
    {
        Items = new List<OrderItem>();
    }

    public decimal GetTotal()
    {
        decimal total = 0;
        foreach (var item in Items)
        {
            total += item.Price * item.Quantity;
        }
        return total;
    }
}

public class ReceiptPrinter
{
    public void PrintReceipt(Order order)
    {
        Console.WriteLine($"Order ID: {order.Id}");
        foreach (var item in order.Items)
        {
            Console.WriteLine($"{item.Name} - {item.Quantity} x {item.Price} = {item.Quantity * item.Price}");
        }
        Console.WriteLine($"Total: {order.GetTotal()}");
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, the Order class is responsible only for managing order data and calculating totals, while the ReceiptPrinter class is responsible for printing the receipt. Each class has a single responsibility.


Step 2: Open-Closed Principle (OCP)

The Open-Closed Principle suggests that software entities should be open for extension but closed for modification. Currently, the GetTotal method in our Order class has hardcoded discount logic for premium customers, which violates OCP. We need to refactor the class to allow adding new discount types without modifying the existing code.

Refactored Code:

public interface IDiscount
{
    decimal ApplyDiscount(decimal total);
}

public class NoDiscount : IDiscount
{
    public decimal ApplyDiscount(decimal total)
    {
        return total;
    }
}

public class PremiumDiscount : IDiscount
{
    public decimal ApplyDiscount(decimal total)
    {
        return total * 0.9m; // 10% discount for premium customers
    }
}

public class Order
{
    public int Id { get; set; }
    public List<OrderItem> Items { get; set; }
    public IDiscount Discount { get; set; }

    public Order(IDiscount discount)
    {
        Items = new List<OrderItem>();
        Discount = discount;
    }

    public decimal GetTotal()
    {
        decimal total = 0;
        foreach (var item in Items)
        {
            total += item.Price * item.Quantity;
        }
        return Discount.ApplyDiscount(total);
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, the Order class doesn't need to be modified when adding new discounts. We can extend the discount system by implementing new IDiscount classes like HolidayDiscount, adhering to OCP.


Step 3: Liskov Substitution Principle (LSP)

The Liskov Substitution Principle states that subclasses should be substitutable for their base classes without altering the correctness of the program. By introducing the IDiscount interface, we've already ensured that any class implementing IDiscount (like PremiumDiscount, NoDiscount, or HolidayDiscount) can replace each other without breaking the functionality.

Example:

public class HolidayDiscount : IDiscount
{
    public decimal ApplyDiscount(decimal total)
    {
        return total * 0.85m; // 15% holiday discount
    }
}

var holidayOrder = new Order(new HolidayDiscount());
Console.WriteLine(holidayOrder.GetTotal());
Enter fullscreen mode Exit fullscreen mode

Any discount class can now be used in place of another, following LSP.


Step 4: Interface Segregation Principle (ISP)

The Interface Segregation Principle suggests that clients should not be forced to depend on interfaces they don't use. Instead of creating one large interface (e.g., IOrderManager), it's better to break it into smaller, more focused interfaces. Let's apply ISP by splitting responsibilities into smaller interfaces for orders and receipt printing.

Refactored Code:

public interface IOrder
{
    decimal GetTotal();
}

public interface IReceiptPrinter
{
    void PrintReceipt(Order order);
}

public class Order : IOrder
{
    public int Id { get; set; }
    public List<OrderItem> Items { get; set; }
    public IDiscount Discount { get; set; }

    public Order(IDiscount discount)
    {
        Items = new List<OrderItem>();
        Discount = discount;
    }

    public decimal GetTotal()
    {
        decimal total = 0;
        foreach (var item in Items)
        {
            total += item.Price * item.Quantity;
        }
        return Discount.ApplyDiscount(total);
    }
}

public class ReceiptPrinter : IReceiptPrinter
{
    public void PrintReceipt(Order order)
    {
        Console.WriteLine($"Order ID: {order.Id}");
        foreach (var item in order.Items)
        {
            Console.WriteLine($"{item.Name} - {item.Quantity} x {item.Price} = {item.Quantity * item.Price}");
        }
        Console.WriteLine($"Total: {order.GetTotal()}");
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, the Order class implements only the IOrder interface, and the ReceiptPrinter class implements only the IReceiptPrinter interface, respecting ISP.


Step 5: Dependency Inversion Principle (DIP)

The Dependency Inversion Principle states that high-level modules should depend on abstractions, not on concrete implementations. We can further improve our Order class by ensuring it depends on an abstraction for discounts (IDiscount), not on concrete implementations like PremiumDiscount or NoDiscount.

Refactored Code:

public interface IDiscount
{
    decimal ApplyDiscount(decimal total);
}

public interface IReceiptPrinter
{
    void PrintReceipt(Order order);
}

public class Order
{
    public int Id { get; set; }
    public List<OrderItem> Items { get; set; }
    private readonly IDiscount _discount;

    public Order(IDiscount discount)
    {
        Items = new List<OrderItem>();
        _discount = discount;
    }

    public decimal GetTotal()
    {
        decimal total = 0;
        foreach (var item in Items)
        {
            total += item.Price * item.Quantity;
        }
        return _discount.ApplyDiscount(total);
    }
}

public class ReceiptPrinter : IReceiptPrinter
{
    public void PrintReceipt(Order order)
    {
        Console.WriteLine($"Order ID: {order.Id}");
        foreach (var item in order.Items)
        {
            Console.WriteLine($"{item.Name} - {item.Quantity} x {item.Price} = {item.Quantity * item.Price}");
        }


 Console.WriteLine($"Total: {order.GetTotal()}");
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, both the Order class and ReceiptPrinter class depend on abstractions (IDiscount, IReceiptPrinter), following the DIP. This ensures that the classes are flexible and can easily use different implementations without being tightly coupled to any one class.


Conclusion

By applying the SOLID principles to a simple Customer Order System example, we refactored the code to become more maintainable, scalable, and flexible. Each principle brings a unique benefit:

  1. Single Responsibility Principle: Makes each class focused on a single task, improving clarity and maintainability.
  2. Open-Closed Principle: Allows extending functionality without modifying existing code, reducing the risk of introducing bugs.
  3. Liskov Substitution Principle: Ensures that subclasses can be used in place of their base classes, preserving correctness.
  4. Interface Segregation Principle: Promotes the use of smaller, more focused interfaces, reducing unnecessary dependencies.
  5. Dependency Inversion Principle: Encourages classes to depend on abstractions rather than concrete implementations, improving flexibility.

By following these principles, your code will be easier to understand, extend, and maintain, making your projects more robust and adaptable to change over time.

Top comments (0)