Open/Closed Principle in C#: Making Your Code Flexible Without Breaking Stuff

· 7 min

Ever built a feature that worked great—until someone added a small change and it all fell apart?

Welcome to the pain of code that violates the Open/Closed Principle.

Software entities should be open for extension, but closed for modification.

It’s the “O” in SOLID, and it’s all about writing code that can grow new behaviors without you having to rewrite what already works.

Let’s unpack that—with real C# code, not vague theory.

Our Old Friend: The InvoiceProcessor

We’ll pick up from where we left off in the SRP post. Here’s a class that’s responsible for calculating invoice totals:

public class InvoiceCalculator
{
    public decimal CalculateTotal(Invoice invoice)
    {
        decimal total = 0;
        foreach (var item in invoice.LineItems)
        {
            total += item.Price * item.Quantity;
        }
        return total;
    }
}

All good. But now your product manager wants to add discounts. Tomorrow, someone else might ask for tax rules. Next week? Promotional pricing based on customer loyalty.

Do we keep modifying this method every time?

🚨 The Problem: Change Means Risk

If you change this class for every new pricing rule, a few things happen:

That’s not sustainable.

Enter the Open/Closed Principle

Rather than adding new logic inside the class, we extend behavior from the outside—through abstraction and composition.

So instead of modifying InvoiceCalculator, we give it a way to plug in pricing strategies.

🏗️ Refactoring for Extensibility

Let’s define a new interface:

public interface IPricingRule
{
    decimal Apply(Invoice invoice, decimal currentTotal);
}

Then we create a base calculator that supports rule injection:

public class FlexibleInvoiceCalculator
{
    private readonly List<IPricingRule> _pricingRules;

    public FlexibleInvoiceCalculator(List<IPricingRule> pricingRules)
    {
        _pricingRules = pricingRules;
    }

    public decimal CalculateTotal(Invoice invoice)
    {
        decimal total = invoice.LineItems
            .Sum(item => item.Price * item.Quantity);

        foreach (var rule in _pricingRules)
        {
            total = rule.Apply(invoice, total);
        }

        return total;
    }
}

Now let’s add a discount rule:

public class TenPercentDiscountRule : IPricingRule
{
    public decimal Apply(Invoice invoice, decimal currentTotal)
    {
        return currentTotal * 0.9m;
    }
}

And another for tax:

public class TaxRule : IPricingRule
{
    public decimal Apply(Invoice invoice, decimal currentTotal)
    {
        return currentTotal * 1.05m; // 5% tax
    }
}

Here’s how you’d use the FlexibleInvoiceCalculator with both the discount and tax rules applied:

// Example invoice setup
var invoice = new Invoice
{
    LineItems = new List<LineItem>
    {
        new LineItem { Price = 100, Quantity = 2 }, // $200
        new LineItem { Price = 50, Quantity = 1 }   // $50
    }
};

// Define pricing rules
var pricingRules = new List<IPricingRule>
{
    new TenPercentDiscountRule(), // 10% off
    new TaxRule()                 // Add 5% tax
};

// Create calculator with rules
var calculator = new FlexibleInvoiceCalculator(pricingRules);

// Calculate final total
decimal finalTotal = calculator.CalculateTotal(invoice);

Console.WriteLine($"Final Total: {finalTotal:C}"); // Output: Final Total: $198.45

You can mix, match, and inject these rules without touching the calculator itself.

Why This Works

Your core logic (the calculator) is closed for modification. You’re not touching its internals anymore.

But it’s open for extension—you can pass in any rule that implements IPricingRule.

This means:

🔍 Real-World Benefits

The Open/Closed Principle helps you:

🧪 A Simple Test

If adding a new behavior means editing existing, working code, you’re probably violating OCP.

If you can write new logic without opening up stable code, you’re doing it right.

Final Thoughts

The Open/Closed Principle is about trust. You trust that your existing logic works, and you want to extend it without messing it up.

Abstraction isn’t overengineering—it’s insurance for your codebase. When your app grows (and it will), code that’s open for extension and closed for modification will save you from a lot of late-night refactors.

Your code should welcome change like an open door, but guard its core like a vault.