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.
Top comments (0)