Design Patterns & Practices  

Open/Closed Principle (OCP) in C#: Extend Without Breaking

Summary

This article explores the Open/Closed Principle (OCP)—software entities should be open for extension but closed for modification. You'll learn how to design C# applications that accommodate new features without changing existing, working code. Through practical examples, we'll transform rigid, if-else-laden code into flexible, extensible architectures using interfaces, abstract classes, and polymorphism.

Prerequisites

  • Understanding of C# interfaces and abstract classes

  • Familiarity with polymorphism and inheritance

  • Knowledge of Single Responsibility Principle (SRP) recommended

  • Basic experience with dependency injection helpful

The Open/Closed Principle states: Software entities should be open for extension but closed for modification.

In other words, you should be able to add new functionality without changing existing code. Sounds impossible? It's not. Let's see how.

The Problem: Modifying Existing Code

Imagine you're building a payment processing system. You start with credit cards:

public class PaymentProcessor
{
    public void ProcessPayment(string paymentType, decimal amount)
    {
        if (paymentType == "CreditCard")
        {
            Console.WriteLine($"Processing credit card payment of ${amount}");
            // Credit card processing logic
            ValidateCreditCard();
            ChargeCreditCard(amount);
        }
    }

    private void ValidateCreditCard() { /* ... */ }
    private void ChargeCreditCard(decimal amount) { /* ... */ }
}

This works fine. Then the business wants to accept PayPal. You modify the class:

public class PaymentProcessor
{
    public void ProcessPayment(string paymentType, decimal amount)
    {
        if (paymentType == "CreditCard")
        {
            Console.WriteLine($"Processing credit card payment of ${amount}");
            ValidateCreditCard();
            ChargeCreditCard(amount);
        }
        else if (paymentType == "PayPal")
        {
            Console.WriteLine($"Processing PayPal payment of ${amount}");
            ValidatePayPalAccount();
            ChargePayPal(amount);
        }
    }

    private void ValidateCreditCard() { /* ... */ }
    private void ChargeCreditCard(decimal amount) { /* ... */ }
    private void ValidatePayPalAccount() { /* ... */ }
    private void ChargePayPal(decimal amount) { /* ... */ }
}

Now they want cryptocurrency. You modify again:

public class PaymentProcessor
{
    public void ProcessPayment(string paymentType, decimal amount)
    {
        if (paymentType == "CreditCard")
        {
            // Credit card logic...
        }
        else if (paymentType == "PayPal")
        {
            // PayPal logic...
        }
        else if (paymentType == "Cryptocurrency")
        {
            Console.WriteLine($"Processing crypto payment of ${amount}");
            ValidateCryptoWallet();
            TransferCrypto(amount);
        }
    }

    // More and more methods...
}

See the problem? Every new payment method requires modifying PaymentProcessor. This violates OCP. You're changing existing, tested code to add features. Each change risks breaking working functionality.

Refactoring with OCP

Let's make this extensible:

public interface IPaymentMethod
{
    void ProcessPayment(decimal amount);
}

public class CreditCardPayment : IPaymentMethod
{
    public void ProcessPayment(decimal amount)
    {
        Console.WriteLine($"Processing credit card payment of ${amount}");
        ValidateCard();
        ChargeCard(amount);
    }

    private void ValidateCard() { /* ... */ }
    private void ChargeCard(decimal amount) { /* ... */ }
}

public class PayPalPayment : IPaymentMethod
{
    public void ProcessPayment(decimal amount)
    {
        Console.WriteLine($"Processing PayPal payment of ${amount}");
        ValidateAccount();
        ChargeAccount(amount);
    }

    private void ValidateAccount() { /* ... */ }
    private void ChargeAccount(decimal amount) { /* ... */ }
}

public class CryptocurrencyPayment : IPaymentMethod
{
    public void ProcessPayment(decimal amount)
    {
        Console.WriteLine($"Processing crypto payment of ${amount}");
        ValidateWallet();
        TransferFunds(amount);
    }

    private void ValidateWallet() { /* ... */ }
    private void TransferFunds(decimal amount) { /* ... */ }
}

public class PaymentProcessor
{
    public void ProcessPayment(IPaymentMethod paymentMethod, decimal amount)
    {
        paymentMethod.ProcessPayment(amount);
    }
}

Now adding a new payment method is simple:

public class BankTransferPayment : IPaymentMethod
{
    public void ProcessPayment(decimal amount)
    {
        Console.WriteLine($"Processing bank transfer of ${amount}");
        // Bank transfer logic
    }
}

No changes to PaymentProcessor. It's closed for modification but open for extension. We added functionality by creating a new class, not modifying existing ones.

The Discount Calculator Problem

Let's look at another common scenario. You're building an e-commerce system with different discount types:

public class DiscountCalculator
{
    public decimal CalculateDiscount(string customerType, decimal orderTotal)
    {
        if (customerType == "Regular")
        {
            return 0; // No discount
        }
        else if (customerType == "Silver")
        {
            return orderTotal * 0.10m; // 10% off
        }
        else if (customerType == "Gold")
        {
            return orderTotal * 0.20m; // 20% off
        }
        else if (customerType == "Platinum")
        {
            return orderTotal * 0.30m; // 30% off
        }

        return 0;
    }
}

Adding a "Diamond" tier means modifying this method. What if discount rules become more complex? What if Silver customers get 15% off on weekends? The if-else chain grows uncontrollably.

OCP-Compliant Design

public interface IDiscountPolicy
{
    decimal CalculateDiscount(decimal orderTotal);
}

public class RegularCustomerDiscount : IDiscountPolicy
{
    public decimal CalculateDiscount(decimal orderTotal)
    {
        return 0; // No discount
    }
}

public class SilverCustomerDiscount : IDiscountPolicy
{
    public decimal CalculateDiscount(decimal orderTotal)
    {
        return orderTotal * 0.10m;
    }
}

public class GoldCustomerDiscount : IDiscountPolicy
{
    public decimal CalculateDiscount(decimal orderTotal)
    {
        return orderTotal * 0.20m;
    }
}

public class PlatinumCustomerDiscount : IDiscountPolicy
{
    public decimal CalculateDiscount(decimal orderTotal)
    {
        return orderTotal * 0.30m;
    }
}

public class DiscountCalculator
{
    public decimal CalculateDiscount(IDiscountPolicy policy, decimal orderTotal)
    {
        return policy.CalculateDiscount(orderTotal);
    }
}

Want to add Diamond tier? Create a new class:

public class DiamondCustomerDiscount : IDiscountPolicy
{
    public decimal CalculateDiscount(decimal orderTotal)
    {
        return orderTotal * 0.40m;
    }
}

Need weekend bonuses for Silver customers? Extend the class:

public class SilverWeekendDiscount : IDiscountPolicy
{
    public decimal CalculateDiscount(decimal orderTotal)
    {
        var baseDiscount = orderTotal * 0.10m;

        if (DateTime.Now.DayOfWeek == DayOfWeek.Saturday || 
            DateTime.Now.DayOfWeek == DayOfWeek.Sunday)
        {
            return orderTotal * 0.15m; // 15% on weekends
        }

        return baseDiscount;
    }
}

Your core DiscountCalculator never changes. It's closed for modification.

The Logging System Evolution

Here's a scenario where OCP prevents cascading changes. You start with console logging:

public class OrderService
{
    public void PlaceOrder(Order order)
    {
        // Process order...

        Console.WriteLine($"Order {order.Id} placed successfully");
    }
}

public class PaymentService
{
    public void ProcessPayment(Payment payment)
    {
        // Process payment...

        Console.WriteLine($"Payment {payment.Id} processed successfully");
    }
}

Then you need file logging. You modify every service:

public class OrderService
{
    public void PlaceOrder(Order order)
    {
        // Process order...

        File.AppendAllText("orders.log", $"Order {order.Id} placed at {DateTime.Now}
");
    }
}

public class PaymentService
{
    public void ProcessPayment(Payment payment)
    {
        // Process payment...

        File.AppendAllText("payments.log", $"Payment {payment.Id} processed at {DateTime.Now}
");
    }
}

Then you need database logging. More modifications everywhere. This violates OCP.

Making Logging Extensible

public interface ILogger
{
    void Log(string message);
}

public class ConsoleLogger : ILogger
{
    public void Log(string message)
    {
        Console.WriteLine($"[{DateTime.Now}] {message}");
    }
}

public class FileLogger : ILogger
{
    private readonly string _filePath;

    public FileLogger(string filePath)
    {
        _filePath = filePath;
    }

    public void Log(string message)
    {
        File.AppendAllText(_filePath, $"[{DateTime.Now}] {message}
");
    }
}

public class DatabaseLogger : ILogger
{
    private readonly string _connectionString;

    public DatabaseLogger(string connectionString)
    {
        _connectionString = connectionString;
    }

    public void Log(string message)
    {
        using (var connection = new SqlConnection(_connectionString))
        {
            connection.Open();
            var command = new SqlCommand("INSERT INTO Logs (Message, Timestamp) VALUES (@msg, @time)", connection);
            command.Parameters.AddWithValue("@msg", message);
            command.Parameters.AddWithValue("@time", DateTime.Now);
            command.ExecuteNonQuery();
        }
    }
}

public class OrderService
{
    private readonly ILogger _logger;

    public OrderService(ILogger logger)
    {
        _logger = logger;
    }

    public void PlaceOrder(Order order)
    {
        // Process order...

        _logger.Log($"Order {order.Id} placed successfully");
    }
}

public class PaymentService
{
    private readonly ILogger _logger;

    public PaymentService(ILogger logger)
    {
        _logger = logger;
    }

    public void ProcessPayment(Payment payment)
    {
        // Process payment...

        _logger.Log($"Payment {payment.Id} processed successfully");
    }
}

Need cloud logging? Just add:

public class CloudLogger : ILogger
{
    private readonly ICloudService _cloudService;

    public CloudLogger(ICloudService cloudService)
    {
        _cloudService = cloudService;
    }

    public void Log(string message)
    {
        _cloudService.SendLog(message, DateTime.Now);
    }
}

OrderService and PaymentService don't change. They're closed for modification but open for extension through ILogger implementations.

Using Abstract Classes for Shared Behavior

Sometimes you want to share behavior while allowing extension. Abstract classes work well:

public abstract class ReportGenerator
{
    public void GenerateReport(string title, List data)
    {
        WriteHeader(title);
        WriteContent(data);
        WriteFooter();
    }

    protected abstract void WriteHeader(string title);
    protected abstract void WriteContent(List data);
    protected abstract void WriteFooter();
}

public class PdfReportGenerator : ReportGenerator
{
    protected override void WriteHeader(string title)
    {
        Console.WriteLine($"PDF Header: {title}");
        // PDF-specific header code
    }

    protected override void WriteContent(List data)
    {
        Console.WriteLine("PDF Content:");
        foreach (var line in data)
        {
            Console.WriteLine($"  {line}");
        }
    }

    protected override void WriteFooter()
    {
        Console.WriteLine("PDF Footer");
    }
}

public class ExcelReportGenerator : ReportGenerator
{
    protected override void WriteHeader(string title)
    {
        Console.WriteLine($"Excel Header: {title}");
        // Excel-specific header code
    }

    protected override void WriteContent(List data)
    {
        Console.WriteLine("Excel Content:");
        foreach (var line in data)
        {
            Console.WriteLine($"  | {line} |");
        }
    }

    protected override void WriteFooter()
    {
        Console.WriteLine("Excel Footer");
    }
}

The GenerateReport method defines the algorithm structure. Subclasses provide specific implementations. Want HTML reports? Extend ReportGenerator. The base class never changes.

Strategy Pattern and OCP

The Strategy pattern is a classic OCP implementation:

public interface IShippingStrategy
{
    decimal CalculateCost(decimal weight, string destination);
}

public class StandardShipping : IShippingStrategy
{
    public decimal CalculateCost(decimal weight, string destination)
    {
        return weight * 0.50m;
    }
}

public class ExpressShipping : IShippingStrategy
{
    public decimal CalculateCost(decimal weight, string destination)
    {
        return weight * 1.50m + 10m; // Extra fee
    }
}

public class InternationalShipping : IShippingStrategy
{
    public decimal CalculateCost(decimal weight, string destination)
    {
        var baseCost = weight * 2.00m;
        var countryFee = GetCountryFee(destination);
        return baseCost + countryFee;
    }

    private decimal GetCountryFee(string destination)
    {
        // Country-specific fees
        return 15m;
    }
}

public class ShippingCalculator
{
    public decimal Calculate(IShippingStrategy strategy, decimal weight, string destination)
    {
        return strategy.CalculateCost(weight, destination);
    }
}

Adding same-day shipping:

public class SameDayShipping : IShippingStrategy
{
    public decimal CalculateCost(decimal weight, string destination)
    {
        return weight * 3.00m + 25m; // Premium pricing
    }
}

ShippingCalculator remains unchanged. Open for extension, closed for modification.

When OCP Can Go Too Far

OCP doesn't mean abstracting everything upfront. That leads to over-engineering. Consider:

Don't abstract if:

  • The feature is unlikely to change or extend

  • You have only one implementation and no plans for more

  • The abstraction makes code harder to understand

Start concrete. Abstract when you need to extend. Don't predict every possible future requirement.

OCP and Testing

OCP makes testing dramatically easier:

// Easy to test because PaymentProcessor depends on IPaymentMethod
public class PaymentProcessorTests
{
    [Test]
    public void ProcessPayment_WithValidPayment_Succeeds()
    {
        // Arrange
        var mockPayment = new Mock();
        var processor = new PaymentProcessor();

        // Act
        processor.ProcessPayment(mockPayment.Object, 100m);

        // Assert
        mockPayment.Verify(p => p.ProcessPayment(100m), Times.Once);
    }
}

Without OCP, you'd need complex mocking or actual payment gateways in tests.

Conclusion

The Open/Closed Principle transforms rigid code into flexible systems. By depending on abstractions (interfaces and abstract classes), you create extension points where new functionality can be added without touching existing, tested code.

This doesn't mean every class needs an interface. It means designing code so that common extension points—like payment methods, discount policies, or logging strategies—can accommodate new variants without modification.

When you find yourself writing if (type == "NewThing"), stop. Ask: "Could I model this as a new implementation of an abstraction?" More often than not, the answer is yes.

In the next article, we'll explore the Liskov Substitution Principle and learn how to use inheritance correctly.

Key Takeaways

  • ✅ Open for extension – Add features by creating new classes, not modifying existing ones

  • ✅ Closed for modification – Working, tested code should rarely change

  • ✅ Use interfaces – Abstract common extension points behind interfaces

  • ✅ Strategy pattern – A classic way to implement OCP

  • ✅ Abstract classes – Share behavior while allowing customization

  • ✅ Easier testing – Abstractions make mocking and testing simple

  • ✅ Don't over-abstract – Abstract when you need extension, not speculatively

  • ✅ Spot violations – Growing if-else chains signal OCP violations

What if-else chains in your code could benefit from OCP? Share your examples below!

References and Further Reading