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.