Quick Recap & Series Intro
Series Articles:
This is Article 2 of 5 in our SOLID series for C# Corner. In Article 1, we covered the Single Responsibility Principle (SRP) — the idea that a class should have one reason to change.
This time, we're moving on to the second letter in SOLID: the Open/Closed Principle (OCP).
We'll explore each remaining principle fully in its own article. Today, it's all about the "O."
Let's move to the second letter: O.
If you haven't read Part 1 yet, it's worth a quick look since these principles build on each other in practice, even though each article stands on its own:
SOLID Principles in C# Series - Part 1: Single Responsibility Principle (SRP)
Up next after this one: the Liskov Substitution Principle (LSP).
What is the Open/Closed Principle?
In plain English: a class should be open for extension, but closed for modification.
That means once you've written and tested a class, you shouldn't need to keep going back and editing its internals every time a new requirement shows up. Instead, you should be able to add new behavior by writing new code — not by touching the old code.
The "closed" part protects what already works. The "open" part means you can still grow the system.
Sounds simple, but in real codebases, this is one of the easiest principles to break without noticing, especially when deadlines are tight and "just add an if-statement" feels like the fastest fix.
A General Real-World Example
Think about the electrical wiring in your house. The wiring itself is fixed — you don't rip open the walls every time you buy a new appliance. Instead, you plug the new appliance into an existing socket.
The socket is open for extension (you can plug in anything compatible) but the wiring behind the wall is closed for modification (you don't rewire the house for a new toaster).
Now imagine a house where every new appliance required an electrician to rewire a section of the wall. That's exhausting, risky, and something is bound to break eventually.
That's exactly what happens in code when every new feature means editing a class that already works.
Why Developers Violate This Principle Without Realizing It
Nobody sets out to break OCP on purpose.
It usually creeps in like this: you write a class with a simple if or switch statement to handle two or three cases. It works fine. Then a new case comes along, and the easiest thing is to add another else if to the same method. Then another.
Six months later, that method is 200 lines long, and every new feature request means opening up a class that ten other things depend on.
The root cause is usually that the class is making decisions based on "type" or "category" using conditional logic, instead of letting different objects know how to handle their own behavior.
It feels productive in the moment — you're shipping the feature — but you're quietly increasing the risk that a future change breaks something unrelated.
C# Example 1 — Discount Calculation
Before (Violates OCP)
public class DiscountCalculator
{
public decimal CalculateDiscount(string customerType, decimal amount)
{
if (customerType == "Regular")
return amount * 0.05m;
else if (customerType == "Premium")
return amount * 0.10m;
else if (customerType == "VIP")
return amount * 0.20m;
return 0;
}
}
Every time a new customer tier is introduced, someone has to open this class and add another else if. That's a modification, not an extension — and it risks affecting the logic for every existing customer type too.
After (Follows OCP)
public interface IDiscountStrategy
{
decimal CalculateDiscount(decimal amount);
}
public class RegularCustomerDiscount : IDiscountStrategy
{
public decimal CalculateDiscount(decimal amount) => amount * 0.05m;
}
public class PremiumCustomerDiscount : IDiscountStrategy
{
public decimal CalculateDiscount(decimal amount) => amount * 0.10m;
}
public class VipCustomerDiscount : IDiscountStrategy
{
public decimal CalculateDiscount(decimal amount) => amount * 0.20m;
}
public class DiscountCalculator
{
public decimal CalculateDiscount(IDiscountStrategy strategy, decimal amount)
=> strategy.CalculateDiscount(amount);
}
What changed: Instead of one class deciding the logic for every customer type, each type now owns its own discount rule behind a shared interface.
Adding a new tier — say, "Platinum" — means writing one new class. DiscountCalculator itself never needs to change again.
C# Example 2 — Notification System
Before (Violates OCP)
public class NotificationService
{
public void Send(string channel, string message)
{
if (channel == "Email")
{
// send email
}
else if (channel == "SMS")
{
// send SMS
}
else if (channel == "Push")
{
// send push notification
}
}
}
This looks harmless with three channels. But add WhatsApp, Slack, or an in-app notification, and this method keeps growing — and every change carries the risk of breaking an existing channel that has nothing to do with the new one.
After (Follows OCP)
public interface INotificationChannel
{
void Send(string message);
}
public class EmailNotification : INotificationChannel
{
public void Send(string message) { /* send email */ }
}
public class SmsNotification : INotificationChannel
{
public void Send(string message) { /* send SMS */ }
}
public class NotificationService
{
private readonly IEnumerable<INotificationChannel> _channels;
public NotificationService(IEnumerable<INotificationChannel> channels)
{
_channels = channels;
}
public void NotifyAll(string message)
{
foreach (var channel in _channels)
channel.Send(message);
}
}
What changed: NotificationService no longer cares what kind of channel it's dealing with. It just loops through whatever channels it was given.
Adding Slack notifications later means creating a SlackNotification class and registering it — no existing code is touched.
Applying This in HRMS Project
Let's bring this into HRMS (Human Resource Management System).
Take payroll, specifically allowance calculation, where rules differ by employee type — Permanent, Contractual, and Intern.
A common first attempt looks like this:
public class PayrollCalculator
{
public decimal CalculateAllowance(string employeeType, decimal baseSalary)
{
if (employeeType == "Permanent")
return baseSalary * 0.20m;
else if (employeeType == "Contractual")
return baseSalary * 0.10m;
else if (employeeType == "Intern")
return 0;
return 0;
}
}
This works fine until HR introduces a new employee category, like "Consultant," with its own allowance rule — and now you're back inside PayrollCalculator, modifying logic that affects every employee type currently in production.
Applying OCP
We let each employee type define its own allowance rule:
public interface IAllowanceRule
{
decimal CalculateAllowance(decimal baseSalary);
}
public class PermanentEmployeeAllowance : IAllowanceRule
{
public decimal CalculateAllowance(decimal baseSalary) => baseSalary * 0.20m;
}
public class ContractualEmployeeAllowance : IAllowanceRule
{
public decimal CalculateAllowance(decimal baseSalary) => baseSalary * 0.10m;
}
public class InternAllowance : IAllowanceRule
{
public decimal CalculateAllowance(decimal baseSalary) => 0;
}
public class PayrollCalculator
{
public decimal CalculateAllowance(IAllowanceRule rule, decimal baseSalary)
=> rule.CalculateAllowance(baseSalary);
}
Now, when HR adds the "Consultant" category, a developer creates a ConsultantAllowance class implementing IAllowanceRule.
PayrollCalculator and every existing employee type's logic stay untouched.
This matters a lot in payroll specifically — it's one of the last places you want an unrelated code change accidentally affecting someone else's salary calculation.
The same pattern applies elsewhere in HRMS: LeaveRequest approval rules that vary by employee level, or AttendanceService rules that differ between remote and on-site staff, are both good candidates for this approach.
Common Mistakes / Pitfalls When Applying This Principle
A frequent mistake is over-engineering small, stable logic into interfaces and strategy classes when a simple if statement would never realistically change.
OCP is about anticipating likely extension points, not creating an interface for every two-line method "just in case."
Another pitfall is creating an abstraction but still leaving a switch statement somewhere else in the code to decide which implementation to use — that switch statement just moved, it didn't disappear.
You want that decision handled by dependency injection or a factory, not duplicated in multiple places.
Finally, some developers extend a class through inheritance just to override one method, without checking whether the base class was actually designed to be extended safely — which can quietly violate the next principle in this series, LSP.
Practical Reasons to NOT Apply OCP
OCP is useful, but it isn't a rule to apply everywhere by default. There are real situations where adding interfaces and extension points costs you more than it gives back.
The Logic Is Genuinely Stable
If a piece of business logic hasn't changed in years and there's no realistic reason it will — say, a calculation based on a fixed government tax slab that only changes once a year through a config update — wrapping it in an interface and a strategy class adds files and indirection without adding value.
A plain method is easier to read and debug.
You Don't Know the Future Shape of the Extension Yet
Designing an abstraction too early, before you've seen at least two or three real variations, often means guessing wrong.
You end up with an interface shaped around the first use case, and the second use case forces you to redesign it anyway — at which point the abstraction didn't save you any work; it just moved the redesign later.
The Team or Codebase Isn't Ready for the Extra Indirection
Strategy classes, factories, and dependency injection make a codebase easier to extend, but harder to step through for someone unfamiliar with the pattern.
On a small team, or in a section of the codebase only one or two people touch rarely, a simple if/else can genuinely be more maintainable than five small classes implementing an interface.
Performance-Critical Paths
Interface dispatch and extra object allocations are usually negligible, but in a tight loop processing millions of records, a straightforward conditional can be measurably faster than resolving a strategy through DI.
In those rare hot paths, it's reasonable to prioritize speed over extensibility.
Short-Lived or Throwaway Code
A one-off migration script, a temporary report, or a proof-of-concept doesn't need to be "open for extension."
It needs to run once or twice and then get deleted.
Applying OCP here is effort spent on a future that will never arrive.
The practical guideline: apply OCP where change is likely and recurring — new employee types, new payment methods, new notification channels.
Skip it where change is rare or speculative.
Good engineering judgment here matters more than mechanically following the principle everywhere.
Quick Checklist
Does adding a new case require editing an existing class, or just adding a new one?
Are you using if/else or switch statements to branch on a "type" or "category" field?
Could this branching logic be replaced by an interface and separate implementing classes?
If a new requirement comes in next month, can you predict which class will need to change?
Is the decision about which implementation to use centralized in one place (like DI), rather than scattered?
Conclusion
The Open/Closed Principle isn't about avoiding change — it's about controlling where change happens.
When new requirements mean adding code instead of editing it, your existing features stay safer and your codebase scales more predictably.
Next up in this series: Article 3 — The Liskov Substitution Principle (LSP), where we'll look at why a subclass should never break the expectations set by its parent class.