Liskov Substitution Principle (LSP) in C# with E-Commerce Payment Example

What is the Liskov Substitution Principle (LSP)?

The Liskov Substitution Principle (LSP) is one of the five SOLID principles of object-oriented programming. It states that objects of a base class should be replaceable with objects of its derived classes without affecting the correctness of the program.

In simple terms, subclasses should follow the behavior of their base class, allowing any code that works with the base class to function correctly with its subclasses.

In this guide, we’ll walk through an e-commerce application where a payment system needs to be implemented and later extended with new providers. First, we’ll look at how the Liskov Substitution Principle (LSP) can be broken, and then demonstrate the correct implementation that follows LSP to make the codebase extensible, scalable, and easy to maintain.

Liskov Substitution Principle (LSP) in C# with real-world e-commerce payment example

Why LSP Matters in C# and SOLID Principles?

LSP is one of the most important for writing maintainable, scalable, and extensible C# code.

Without LSP:

  • Subclasses may break the behavior expected by the base class.
  • Code becomes difficult to test and maintain.
  • Integration with other SOLID principles, like Dependency Inversion (DIP), becomes harder.

Breaking LSP: Common Mistakes in C#

Take an e-commerce application payment system as an example:

public class Payment
{
    public virtual void Process(decimal amount)
    {
        Console.WriteLine($"Processing payment of {amount}");
    }
}

public class CashOnDelivery : Payment
{
    public override void Process(decimal amount)
    {
        throw new NotImplementedException("Cash on Delivery cannot be processed online!");
    }
}

Problem:

  • If we substitute Payment payment = new CashOnDelivery(); and call payment.Process(1000);, it throws an exception, violating LSP.
  • The subclass CashOnDelivery does not behave like a normal Payment.
LSP violation and correct implementation in C# with e-commerce payment example

In the above image code snippet, the Liskov Substitution Principle (LSP) was violated because a derived class inherited from the base class but did not provide proper logic, instead throwing an exception. This approach breaks LSP and leads to fragile code. On the right side, we demonstrated the correct implementation that adheres to LSP, showing how to build an extensible, scalable, and maintainable system by following this SOLID principle.

Proper Implementation of Liskov Substitution Principle (LSP) in C#

To follow LSP, define an abstraction and implement subclasses correctly:

public interface IPayment
{
    void Pay(decimal amount);
}

public class CreditCardPayment : IPayment
{
    public void Pay(decimal amount)
    {
        Console.WriteLine($"Paid {amount:C} using Credit Card.");
    }
}

public class PayPalPayment : IPayment
{
    public void Pay(decimal amount)
    {
        Console.WriteLine($"Paid {amount:C} using PayPal.");
    }
}

public class CashOnDelivery : IPayment
{
    public void Pay(decimal amount)
    {
        Console.WriteLine($"Order placed. Pay {amount:C} on delivery.");
    }
}

✅ Now, With this design, any class implementing the IPayment interface can be used interchangeably, ensuring that subclasses can replace IPayment without causing errors or breaking the functionality of the system.

Now, suppose we want to add a new payment provider, `UPIPayment`. Here’s how we can extend the payment functionality in C# while following the Liskov Substitution Principle (LSP)

E-Commerce Example: Applying LSP with Payment Methods

Let’s extend the example for a real-world e-commerce scenario with multiple payment methods:

public class UpiPayment : IPayment
{
    public void Pay(decimal amount)
    {
        Console.WriteLine($"Paid {amount:C} using UPI.");
    }
}
  1. CreditCardPayment, PayPalPayment, CashOnDelivery, and UpiPayment all follow the same interface.
  2. Checkout code can work with any payment type seamlessly, demonstrating LSP in action.

Integrating Dependency Injection (DI) for LSP Compliance

By using Dependency Injection (DI) in C#, we can choose the payment method dynamically at runtime without changing the existing checkout logic. This approach makes the system flexible, maintainable, and fully compliant with the Liskov Substitution Principle (LSP), allowing new payment types to be added easily.

public interface IPaymentFactory
{
    IPayment GetPaymentMethod(string methodName);
}

public class PaymentFactory : IPaymentFactory
{
    private readonly IServiceProvider _serviceProvider;
    private readonly Dictionary<string, Type> _paymentTypes;

    public PaymentFactory(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
        _paymentTypes = new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase)
        {
            { "creditcard", typeof(CreditCardPayment) },
            { "paypal", typeof(PayPalPayment) },
            { "cod", typeof(CashOnDelivery) },
            { "upi", typeof(UpiPayment) }
        };
    }

    public IPayment GetPaymentMethod(string methodName)
    {
        if (_paymentTypes.TryGetValue(methodName, out var type))
        {
            return (IPayment)_serviceProvider.GetService(type)!;
        }
        throw new ArgumentException($"Payment method '{methodName}' is not supported.");
    }
}

The Checkout class uses the payment factory to select and process the appropriate payment method, keeping the system flexible and LSP-compliant.

public class Checkout
{
    private readonly IPaymentFactory _paymentFactory;

    public Checkout(IPaymentFactory paymentFactory)
    {
        _paymentFactory = paymentFactory;
    }

    public void CompleteOrder(string methodName, decimal amount)
    {
        var paymentMethod = _paymentFactory.GetPaymentMethod(methodName);
        paymentMethod.Pay(amount);
    }
}

DI container registration:

var services = new ServiceCollection();
services.AddTransient<CreditCardPayment>();
services.AddTransient<PayPalPayment>();
services.AddTransient<CashOnDelivery>();
services.AddTransient<UpiPayment>();
services.AddSingleton<IPaymentFactory, PaymentFactory>();
services.AddTransient<Checkout>();
var serviceProvider = services.BuildServiceProvider();

Example of Selecting Payment Method at Checkout Time

var checkout = serviceProvider.GetRequiredService<Checkout>();
Console.WriteLine("Choose payment method: creditcard / paypal / cod / upi");
var choice = Console.ReadLine();
checkout.CompleteOrder(choice!, 1500);

Output based on user payment selection

User selects PayPal:
Processing payment via PayPal...
Paid ₹1,500.00 using PayPal.
User selects UPI
Processing payment via UPI...
Paid ₹1,500.00 using UPI.
User selects Cash on Delivery:
Processing payment via COD...
Order placed. Pay ₹1,500.00 on delivery.

Summary

In this article, we explored how violating LSP affects code and then walked through a step-by-step implementation of LSP the right way, making our applications more flexible, scalable, and maintainable.
The Liskov Substitution Principle (LSP) in C# ensures flexible, scalable, and maintainable code. By applying this SOLID principle, derived classes can replace their base classes without breaking functionality.
Our e-commerce payment example with Dependency Injection in C# shows how PayPal, UPI, COD, and Credit Card work seamlessly.

🔗 Explore More from My Articles

💡 If this content helped you, do me a small favor:
👉 Repost & share with your network so more developers can benefit.
👉 Comment below with your thoughts — I’d love to hear how you approach these topics.

Together, we can share knowledge, grow as developers, and build cleaner .NET codebases 💻✨

Open/Closed Principle in C#: Step-by-Step Guide with real-world Examples

In this article, I will break down the Open/Closed Principle in C# with step-by-step, real-world e-commerce examples. You’ll learn how to implement OCP to manage discounts and other evolving requirements, ensuring your application stays flexible, scalable, and maintainable over time.

The Open/Closed Principle (OCP), one of the five SOLID principles, provides a solution. OCP states that software entities should be “open for extension but closed for modification.” In practice, this means you can introduce new features by extending existing components rather than rewriting them, keeping your codebase clean, stable, and easy to scale.

Understanding the Open/Closed Principle in C#

What is the Open/Closed Principle (OCP)?

The Open/Closed Principle in C# states that classes should be open for extension but closed for modification. This means you can add new behavior without changing stable code. For example, instead of editing existing methods, you create new classes that extend functionality.

Why OCP Matters in Modern Software Development

In dynamic industries such as e-commerce, business requirements frequently change, with new discounts or shipping methods being introduced regularly. By following the Open/Closed Principle (OCP), your system can accommodate these changes by allowing new features to be added through extension, rather than by modifying existing, well-tested code. This approach helps maintain application stability and scalability, ensuring your software remains robust and adaptable as your business grows.
 

The Role of OCP in SOLID Principles

OCP is the second principle in SOLID and works alongside SRP and DIP to ensure clean architecture. It reduces technical debt, improves testability, and enhances maintainability. By applying OCP in C#, you make your codebase more resilient to change.

Real-World Challenges in E-Commerce Applications

E-commerce applications often need to adjust for seasonal sales, integrate new payment gateways, or add delivery providers. Without applying the Open/Closed Principle (OCP), each change forces modifications to existing code, raising risks and complexity.

In this article, we’ll explore a discount strategy example to show how challenging it becomes to manage growth when OCP is not followed. 

In this example, we applied a fixed discount. If we later need to add a percentage-based or seasonal discount, we would have to modify the existing code, resulting in extra testing, potential regressions, and an increased risk of introducing bugs.

public class DiscountService
{
    public decimal ApplyDiscount(string discountType, decimal totalAmount)
    {
        if (discountType == "Percentage")
        {
            return totalAmount - (totalAmount * 0.10m);
        }
        else if (discountType == "Fixed")
        {
            return totalAmount - 500;
        }
        else
        {
            return totalAmount;
        }
    }
}

Open/Closed Principle Explained with C# Examples

❌ OCP Violated – Bad Example in E-Commerce

  • In this example, the discount logic is directly hardcoded inside the checkout method.
  • Whenever we need a new discount type, we must change this same method.
  • This creates unnecessary dependency and makes the code harder to maintain.
  • Such an approach breaks the Open/Closed Principle (OCP).

Imagine you need to add two new types of discounts , one for seasonal offers and another for loyalty rewards for our valued customers. To achieve this, you’d add two new if conditions as shown below by extending the existing code with additional blocks.

public decimal ApplyDiscount(string type, decimal price, decimal rate, int loyaltyPoints)
{
    if (type == "Fixed") // Fixed discount
    {
        return price - 50;
    }
    if (type == "Percentage") // Percentage discount
    {
        return price - (price * 0.10m);
    }
    if(type == "Seasonal") // Seasonal discount
    {
         return price - (price * rate);
    }
    if(type == "Loyalty") // loyalty discount
    {
         return loyaltyPoints >= 1000 ? Math.Max(0, price * 0.95m) : price;
    }
    return price;
}
  • In the above example, we introduced two additional parameters to the method and added two new if blocks for handling extra discount types.
  • This approach makes the code tightly coupled, as any new discount type will require modifying the existing method.
  • Such changes increase the risk of breaking existing functionality and make unit testing more complex, since every change demands additional testing.
  • To overcome this, we should aim for a loosely coupled and scalable design that minimizes maintenance overhead in the long run.
  • This is where the Single Responsibility Principle (SRP) comes into play, helping us achieve cleaner, more maintainable, and extensible code.

Open/Closed Principle in E-Commerce: Step-by-Step Refactoring Guide

1. Create Interface
public interface IDiscountStrategy
{
    decimal ApplyDiscount(decimal price);
}
2. Implement in Classes
public class FixedDiscount : IDiscountStrategy
{
    public decimal ApplyDiscount(decimal price) => price - 50;
}

public class PercentageDiscount : IDiscountStrategy
{
    public decimal ApplyDiscount(decimal price) => price - (price * 0.10m);
}

public sealed class SeasonalDiscount : IDiscountStrategy
{
    private readonly decimal _rate; // e.g., 0.15m
    public SeasonalDiscount(decimal rate = 0.15m) => _rate = rate;
    public decimal Apply(decimal price, OrderContext ctx) => ctx.IsSeasonal ? Math.Max(0, price - price * _rate) : price;
}

public sealed class LoyaltyDiscount : IDiscountStrategy
{
    public decimal Apply(decimal price, OrderContext ctx) => ctx.LoyaltyPoints >= 1000 ? Math.Max(0, price * 0.95m) : price;
}
3. Register in Startup
var builder = WebApplication.CreateBuilder(args);

// Register implementations with the interface
builder.Services.AddTransient<IDiscountStrategy, FixedDiscount>();
builder.Services.AddTransient<IDiscountStrategy, PercentageDiscount>();
builder.Services.AddTransient<IDiscountStrategy, SeasonalDiscount>();
builder.Services.AddTransient<IDiscountStrategy, LoyaltyDiscount>();
builder.Services.AddTransient<IDiscountStrategyFactory, DiscountStrategyFactory>();


var app = builder.Build();

app.Run();

4. Named/Keyed Strategy Pattern (Custom Factory)

public interface IDiscountStrategyFactory
{
    IDiscountStrategy GetStrategy(string type);
}

public class DiscountStrategyFactory : IDiscountStrategyFactory
{
    private readonly IEnumerable<IDiscountStrategy> _strategies;

    public DiscountStrategyFactory(IEnumerable<IDiscountStrategy> strategies)
    {
        _strategies = strategies;
    }

    public IDiscountStrategy GetStrategy(string type)
    {
        return type switch
        {
            "Fixed" => _strategies.OfType<FixedDiscount>().First(),
            "Percentage" => _strategies.OfType<PercentageDiscount>().First(),
            "Seasonal" => _strategies.OfType<SeasonalDiscount>().First(),
            "Loaylty" => _strategies.OfType<LoyaltyDiscount>().First(),
            _ => throw new ArgumentException("Invalid discount type")
        };
    }
}
5. Create a service and inject DiscountStrategyFactory
public class CheckoutService
{
    private readonly IDiscountStrategyFactory _factory;

    public CheckoutService(IDiscountStrategyFactory factory)
    {
        _factory = factory;
    }
cs
    public decimal GetFinalPrice(decimal price, string type)
    {
        var strategy = _factory.GetStrategy(type);
        return strategy.ApplyDiscount(price);
    }
}

Benefits of Following the Open/Closed Principle

Improved Maintainability and Scalability

The Open/Closed Principle allows new features to be introduced by adding new classes rather than modifying existing ones. This ensures a more adaptable and scalable system architecture.

Minimized Risk of Bugs and Faster Feature Delivery

By avoiding changes to existing code, the likelihood of introducing defects is significantly reduced. This leads to greater stability, smoother testing, and faster release cycles.

Cleaner Architecture and Long-Term Cost Savings

OCP promotes a modular, well-structured codebase that is easier to extend, maintain, and test. This reduces technical debt, enhances system reliability, and lowers long-term development costs.

Summary

The article explains the Open/Closed Principle (OCP) in C#. It shows how this principle keeps software open for extension but closed for modification. Using real-world e-commerce scenarios, it highlights drawbacks of tightly coupled code, such as adding new discount types using multiple if blocks. This approach increases complexity, risks, and testing effort.

With a step-by-step refactoring guide, the article shows how to apply the Strategy Pattern and Dependency Injection. Discount types (Fixed, Percentage, Seasonal, Loyalty) become independent classes. This ensures scalability, clean architecture, and less maintenance overhead.
Following OCP lets developers build flexible, stable, and extensible systems. It helps minimize bugs, speed up feature delivery, and save on long-term development costs.