Open/Closed Principle in C#: Making Your Code Flexible Without Breaking Stuff
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:
* You risk breaking existing logic.
* You have to re-test everything.
* Your code becomes a jungle of `if` statements.
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:
* ✅ New logic = new classes, not risky edits.
* ✅ Old logic stays safe.
* ✅ Behavior is pluggable, testable, and isolated.
## 🔍 Real-World Benefits
The Open/Closed Principle helps you:
* ✨ Add features faster.
* 🚫 Avoid regressions.
* 🔧 Create modular code that adapts to new requirements without breaking old ones.
* ✨ Encourage team collaboration—each rule can be owned/tested by different devs.
## 🧪 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.