Open Closed Principle C#: Extending Without Modifying
The open closed principle c# developers rely oncomes down to a single idea: software entities should be open for extension, but closed for modification. It sounds almost contradictory at first. How can something be both open and closed at the same time?
The answer is layering. You design a stable core that changes less often and exposes clear extension points. Then you design extension points that let new behavior arrive from the outside. When a new requirement shows up, you write new code -- you don't edit existing code.
Bertrand Meyer introduced the principle in 1988. Robert Martin later reframed it in his SOLID principles work, making it approachable for object-oriented languages like C#. The core insight is this: changing tested, deployed code is risky. Every modification is a potential regression. Every regression is a support ticket, a hotfix, a frustrated user. Design your classes so that adding a new feature means writing new code, not cracking open something that already works.
What "Closed for Modification" Means for open closed principle c# Design
The word "closed" is easy to misread. It doesn't mean the code is frozen in amber, never to be touched again. It means the code's core logic is stable. The tested behavior doesn't change. Consumers of the code don't need to update their dependencies.
Think about a payment processor. If you have a PaymentService that handles credit card payments and it passes all its tests today, adding PayPal support shouldn't require touching PaymentService. The existing credit card logic is stable. Closed. You add PayPal by adding a new implementation -- not by editing the existing class.
The failure mode is recognizable. A method with a growing switch statement. An if-else chain that gets a new branch every sprint. Every addition touches the same line of code. Every change re-opens something you already tested. That's the open closed principle in C# being violated in slow motion.
In practice, "closed for modification" means:
- The class passes its tests today and will still pass them after you add new behavior elsewhere
- Existing method signatures and contracts don't shift under consumers
- The change path for "add new behavior" doesn't go through the existing class
What "Open for Extension" Looks Like in C#
"Open for extension" means you've built seams into the design. Extension points. Places where new behavior can plug in without disrupting the existing code. The open closed principle c# makes this achievable through interfaces and abstract base classes.
In C#, those extension points are:
- Interfaces -- define a contract; new implementations add new behavior
- Abstract base classes -- define a skeleton; subclasses fill in the variable parts
- Default interface methods -- add behavior to an interface without breaking existing implementors
- Extension methods -- add behavior to types you don't own
The goal is the same across all of them: new behavior arrives as new code, not as edits to old code.
The Problem: A Discount Calculator That Grows Forever
Here's the classic OCP violation. A discount calculator that branches on a string to decide which discount to apply:
// Violates OCP -- every new discount type requires modifying this class
public class DiscountCalculator
{
public decimal Calculate(string discountType, decimal price)
{
return discountType switch
{
"student" => price * 0.85m,
"senior" => price * 0.80m,
"employee" => price * 0.70m,
_ => price
};
}
}
Three discount types -- straightforward. But watch what happens when the product team adds a "loyalty" discount. And a "holiday" discount. And a "bulk" discount. Each new type means:
- Finding this file
- Adding a new case to the switch
- Verifying the existing cases still work
- Re-deploying the whole class
That's a fragile process. The class becomes a maintenance magnet. Every team member who adds a discount type is touching the same code. Merge conflicts. Missed cases. Late-night hotfixes. This is exactly what the open closed principle is designed to prevent.
The Fix: Interfaces and the Strategy Pattern
Extract the varying behavior behind an interface. Each discount type becomes its own isolated class. The calculator depends on the abstraction -- not the concrete types.
// The stable abstraction -- this never changes
public interface IDiscountStrategy
{
decimal Apply(decimal price);
}
// Each type is isolated -- changes to one don't affect others
public sealed class StudentDiscount : IDiscountStrategy
{
public decimal Apply(decimal price) => price * 0.85m;
}
public sealed class SeniorDiscount : IDiscountStrategy
{
public decimal Apply(decimal price) => price * 0.80m;
}
public sealed class EmployeeDiscount : IDiscountStrategy
{
public decimal Apply(decimal price) => price * 0.70m;
}
// The calculator is now closed for modification
public sealed class DiscountCalculator(IDiscountStrategy strategy)
{
public decimal Calculate(decimal price) => strategy.Apply(price);
}
Adding a loyalty discount now means writing one new class:
// New behavior arrives as new code -- zero edits to existing classes
public sealed class LoyaltyDiscount : IDiscountStrategy
{
public decimal Apply(decimal price) => price * 0.75m;
}
The DiscountCalculator class is completely untouched. Its unit tests still pass. The existing strategies are untouched. You've extended the system's behavior without modifying a single line of existing code.
That can help achieve what the open closed principle c# aims for. Not every variation deserves its own strategy type -- configuration tables, data-driven rules, or simple pattern matching can also be good fits.
Wiring It Together with .NET 10 Dependency Injection
In a real .NET 10 application, you'd register these strategies with the built-in DI container. Keyed services (introduced in .NET 8 and refined since) make this particularly clean:
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
var builder = WebApplication.CreateBuilder(args);
// Register strategies -- adding a new type is a one-line DI registration
builder.Services.AddKeyedScoped<IDiscountStrategy, StudentDiscount>("student");
builder.Services.AddKeyedScoped<IDiscountStrategy, SeniorDiscount>("senior");
builder.Services.AddKeyedScoped<IDiscountStrategy, EmployeeDiscount>("employee");
builder.Services.AddKeyedScoped<IDiscountStrategy, LoyaltyDiscount>("loyalty");
builder.Services.AddScoped<DiscountCalculatorFactory>();
var app = builder.Build();
// Factory resolves the correct strategy by key -- open to new types, closed to modification
public sealed class DiscountCalculatorFactory(
IServiceProvider serviceProvider,
ILogger<DiscountCalculatorFactory> logger)
{
public DiscountCalculator CreateFor(string discountType)
{
var strategy = serviceProvider.GetKeyedService<IDiscountStrategy>(discountType);
if (strategy is null)
{
logger.LogWarning(
"No discount strategy found for {DiscountType}. Applying no discount.",
discountType);
strategy = new NoDiscount();
}
return new DiscountCalculator(strategy);
}
}
// Null object pattern -- represents "no discount" without throwing or returning null
public sealed class NoDiscount : IDiscountStrategy
{
public decimal Apply(decimal price) => price;
}
New discount types arrive in two steps: write the class, add the DI registration. The factory, the calculator, and every existing strategy class remain unchanged. That's OCP at the architecture level. This pattern is pragmatic but resembles service location -- a tradeoff worth being aware of.
Abstract Base Classes vs Interfaces for OCP
Both abstract classes and interfaces can enable OCP. The choice depends on what the implementations share.
Use interfaces when:
- Implementing types share no common logic
- You want maximum flexibility (a class can implement multiple interfaces)
- The contract is purely behavioral
Use abstract base classes when:
- All subtypes share some common implementation
- You want to define an algorithm skeleton that subclasses fill in
- You need to enforce a structural template for the behavior
The Template Method Design Pattern in C# is the clearest example of the abstract class approach to OCP. The base class defines the invariant steps; subclasses override only the variable parts. No modification to the base class is needed when adding a new subtype.
// Template method approach -- base class defines the structure; subclasses vary the details
public abstract class ReportGeneratorBase
{
// Template method -- never needs to change
public string Generate(IEnumerable<string> data)
{
var header = BuildHeader();
var body = BuildBody(data);
var footer = BuildFooter();
return $"{header}
{body}
{footer}";
}
protected abstract string BuildHeader();
protected abstract string BuildBody(IEnumerable<string> data);
protected abstract string BuildFooter();
}
// New report type -- zero changes to ReportGeneratorBase or any other generator
public sealed class CsvReportGenerator : ReportGeneratorBase
{
protected override string BuildHeader() => "id,name,value";
protected override string BuildBody(IEnumerable<string> data) => string.Join("
", data);
protected override string BuildFooter() => string.Empty;
}
public sealed class HtmlReportGenerator : ReportGeneratorBase
{
protected override string BuildHeader() => "<table>";
protected override string BuildBody(IEnumerable<string> data) =>
string.Join("
", data.Select(row => $"<tr><td>{row}</td></tr>"));
protected override string BuildFooter() => "</table>";
}
For a detailed comparison of when to use each approach, see Template Method vs Strategy Pattern in C#.
Strategy Pattern as the Canonical OCP Enabler
The open closed principle c# and the Strategy pattern are natural allies. The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable.The client class depends only on the abstraction. New algorithms arrive as new classes -- the client never changes.
The Visitor Design Pattern in C# is another powerful OCP enabler. It lets you add new operations to an existing class hierarchy without modifying those classes. When the operation varies but the data structure is stable, Visitor is a strong option. Visitor vs Strategy Pattern in C# covers exactly how to choose between them.
Structural patterns enforce OCP at the architecture level too. The Proxy vs Decorator Pattern in C# shows another dimension of OCP -- both patterns add behavior to existing classes without modifying them. The Bridge Design Pattern in C# separates abstraction from implementation so both can vary independently -- classic OCP thinking. The Facade Design Pattern in C# creates a stable surface that isolates consumers from complexity behind it.
The Mediator Design Pattern in C# applies OCP at the message-handling level. New handlers are added without touching the mediator or existing handlers. Every new command or query type adds one class. Nothing else changes.
OCP in .NET 10: Default Interface Methods and Extension Methods
Modern C# gives you two additional tools for OCP that work at the language level.
Default interface methods (available since C# 8) are one option for evolving contracts without breaking existing implementations -- though they can also obscure behavior if overused. They let you add new behavior to an existing interface without requiring changes to any class that already implements it:
public interface IDiscountStrategy
{
decimal Apply(decimal price);
// Added in a later iteration -- existing implementations get this for free
string Describe() => $"Discount strategy: {GetType().Name}";
// Another default -- existing classes don't need to override
bool IsApplicable(decimal price) => price > 0;
}
Every existing IDiscountStrategy implementation gets Describe() and IsApplicable() for free. You've extended the interface -- a traditionally closed-for-modification construct -- without touching a single implementing class.
Extension methods let you add behavior to types you don't own, or add utility methods without changing the interface contract at all:
public static class DiscountStrategyExtensions
{
// Adds auditing behavior without modifying any strategy class
public static decimal ApplyWithAudit(
this IDiscountStrategy strategy,
decimal price,
ILogger logger)
{
var result = strategy.Apply(price);
logger.LogInformation(
"Applied {Strategy}: {Original:C} -> {Discounted:C}",
strategy.GetType().Name, price, result);
return result;
}
}
You've extended the behavior of every IDiscountStrategy implementation without modifying any of them. That's the open closed principle c# in its purest modern form.
Common OCP Mistakes to Avoid
These mistakes undermine the open closed principle c# benefits developers rely on.
A few pitfalls show up repeatedly when teams try to apply OCP.
Over-abstracting too early. If you only have one discount type today and no concrete plans for more, an interface adds complexity without benefit. YAGNI applies. Design for OCP where you can clearly see variability coming -- not everywhere by default.
Treating OCP as a ban on refactoring. When requirements change in fundamentally unexpected ways, the right move is to refactor. OCP reduces the need for modification; it doesn't eliminate it. The principle is a design target for the common case, not an ironclad law.
Forgetting the Null Object pattern. When you build extension-point architectures, you need a safe default when no implementation matches. The NoDiscount class above is the example. Always have a sensible fallback.
Applying OCP only to classes, not to methods. A method with a growing switch statement is violating OCP at the method level. The same principles apply at every level of granularity.
FAQ
What is the open closed principle in C# in simple terms?
The open closed principle c# means a class should be designed so you can add new behavior by writing new code rather than by editing existing code. In C#, you achieve this through interfaces, abstract classes, and patterns like Strategy -- so new behavior arrives as new implementations, not as modifications to existing classes.
Does "closed for modification" mean the code can never change?
No. "Closed" means the core tested logic is stable and doesn't need to change when you add new functionality. It doesn't mean the code is immutable forever. When fundamental requirements change, refactoring is appropriate and expected. OCP is a design goal for the common case, not a rigid rule about never editing files.
What is the difference between OCP and the Strategy pattern?
OCP is a design principle -- a goal. The Strategy pattern is a specific design pattern that makes it easy to achieve that goal. Strategy is one of the most direct OCP tools, but it's not the only one. Default interface methods, extension methods, and the Template Method pattern all implement OCP in different contexts.
When should I use an abstract class instead of an interface for OCP?
Use an abstract class when your implementations share meaningful logic that belongs in a base class -- for example, a common validation step or a shared logging call. The Template Method pattern is the classic abstract class approach to OCP. Use an interface when implementations are independent and share no code.
Can OCP lead to too many small classes?
Yes, it can. A codebase with dozens of tiny strategy classes for every possible variation can become hard to navigate. The solution is balance: apply OCP where variability is real and anticipated. Use configuration or data-driven approaches where a simple value is all that varies. Refactor to OCP when a second concrete variation appears -- not before.
How does dependency injection help implement OCP in .NET?
Dependency injection naturally supports OCP by making classes depend on abstractions rather than concrete types. When the implementation varies, you change the DI registration -- not the class that uses it. .NET 10's keyed services make this particularly clean for scenarios like discount strategies, where the right implementation is selected by key at runtime.
Does OCP apply to methods, not just classes?
Absolutely. A method with a switch statement or if-else chain that needs a new branch for every new case is violating OCP at the method level. Extracting the varying logic into a polymorphic call or a strategy object fixes the violation regardless of how the rest of the class is designed. The principle applies at every granularity level.
Conclusion
The open closed principle c# is one of the most impactful ideas in software design. It shifts the cost of adding new behavior from "modify something that works" to "write something new." That's a much safer transaction.
One common approach is to identify what varies, extract it behind an interface or abstract class, and let new implementations deliver new behavior without touching the existing ones. Wire it together with dependency injection. Use default interface methods and extension methods when you need to extend behavior at the language level.
Start with the class in your current codebase that has the longest switch statement. That's your OCP violation. Refactor it to use an interface and a small family of strategy classes. Watch how much simpler the next feature request becomes.
The open closed principle in C# isn't about perfection. It's about lowering the cost and risk of change. That's a goal worth designing for every single time.

