The Open-Closed Principle (OCP) is the “O” in SOLID, the five object-oriented design principles every C# and .NET developer eventually runs into. In one sentence, the Open-Closed Principle (sometimes written Open-Close Principle) says:
Software modules should be open for extension but closed for modification.
In plain C# terms: you should be able to add a new behavior to your system by writing a new class rather than editing existing, already-tested code. This article explains what OCP really means, shows two C# code examples (the naive version and the refactored one), and gives a pragmatic way to apply it in real .NET projects without over-engineering.
Let’s refine the definition this way:
- A module is open if it is still available for extension. For example, it should be possible to add fields to the data structures it contains, or new elements to the set of functions it performs.
- A module is closed if it is available for use by other modules. This assumes that the module has been given a well-defined, stable description (the interface, in the sense of information hiding).
There have been long debates about the meaning of this simple definition and its implications for the day-to-day use of Object-Oriented Programming (OOP).
Bertrand Meyer is generally credited for originating the term open-closed principle, which appeared in his 1988 book Object-Oriented Software Construction. So the idea is old, predates the SOLID acronym itself, and has aged well.
SOLID Principles Summary
Before digging into the Open-Closed Principle, here is where it sits among the five SOLID design principles in C#:
- The Single Responsibility Principle (SRP): A class should have one reason to change. This principle is about how to partition your logic into classes and avoid ending up with monster classes (known as a god class).
- The Open-Closed Principle (OCP): Modules should be open for extension and closed for modification. To implement a new feature, it is better to add a new derived class than to modify existing code.
- The Liskov Substitution Principle (LSP): Methods that use references to base classes must be able to use objects of derived classes without knowing it. Array implements IList<T> but throws NotSupportedException on IList<T>.Add(). This is a clear LSP violation.
- The Interface Segregation Principle (ISP): The client should not depend by design on methods it does not use. This is why interfaces like IReadOnlyCollection<T> have been introduced. Often the client just needs a subset of features, like read-only access instead of full read-write access.
- The Dependency Inversion Principle (DIP): Depend on abstractions, not on implementations. Interfaces change far less often than classes, especially the ones that abide by ISP and LSP.
Notice how tightly OCP connects to the other four: you reach the Open-Closed Principle through good abstractions (DIP), well-behaved subtypes (LSP), and focused interfaces (ISP and SRP). They reinforce each other.
Open-Closed Principle Explained with a C# Example
Look at the C# code below. It tries to provide a universal Drawer class that can draw both a Circle and a Square. The problem is that as soon as a new kind of shape shows up, like a Triangle, the Drawer class has to be modified. We’d much rather have a truly universal Drawer that doesn’t depend on each concrete shape class.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
public class Circle { } public class Square { } public static class Drawer { public static void DrawShapes(IEnumerable<object> shapes) { foreach (object shape in shapes) { if (shape is Circle) { DrawCircle(shape as Circle); } else if (shape is Square) { DrawSquare(shape as Square); } } } private static void DrawCircle(Circle circle) { /*Draw circle*/ } private static void DrawSquare(Square square) { /*Draw Square*/ } } |
The smell here is the chain of is / as type checks. Every new shape forces you to reopen DrawShapes() and add another branch. That is exactly the “modification” OCP wants you to avoid.
Refactoring with the Open-Closed Principle
If you’ve practiced object-oriented programming, the fix should feel obvious: push the behavior down into the shapes through an abstraction.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
public interface IShape { void Draw(); } public class Circle : IShape { public void Draw() { /*Draw circle*/ }} public class Square : IShape { public void Draw() { /*Draw Square*/ } } public static class Drawer { public static void DrawShapes(IEnumerable<IShape> shapes) { foreach (IShape shape in shapes) { shape.Draw(); } } } |
Here is what this refactoring buys us:
- We introduced a new abstraction, IShape, to represent a concept we already had in mind. In the first sample, DrawShapes() already accepted an untyped sequence of shapes. With the new IShape abstraction, the design is now open to accept more shapes like Triangle or Pentagon.
- The DrawShapes() method draws any new shape with zero modification. Its implementation is closed, and because of that it is universal.
Benefits of the Open-Closed Principle
OCP is all about anticipating future changes so that, when a change arrives:
- The existing code is left untouched. Here that means the DrawShapes() body, the IShape interface, and the Circle and Square classes.
- The new code lives in new classes that implement the existing abstraction, such as Triangle and Pentagon.
Code you never touch is code you never break. That is the payoff: fewer regressions, smaller diffs, and a stable, well-tested core that keeps working as the system grows.
Anticipating Variations: the Point of Variation Principle
What is the Point of Variation principle?
Another way to look at OCP is the Point of Variation (PV) principle, which states:
Identify points of predicted variation and create a stable interface around them.
I find PV more useful than OCP because it is actionable. First, identify the potential variations. Second, build the right abstractions around them.
The real challenge is anticipation
But the real challenge is anticipation, and anticipating is hard. If anticipation were easy we’d all be billionaires in bitcoin, wouldn’t we? When you try to predict the future, the risk is high on two fronts:
- You anticipate variations that never vary. This is exactly what the YAGNI principle warns about (You Aren’t Gonna Need It): always implement things when you actually need them, never when you just foresee that you’ll need them. Building and maintaining an abstraction has a cost, and an abstraction you don’t use is wasted resources.
- You fail to anticipate the variation that turns out to be needed. Once that need becomes real, it is your job as a developer to refactor: create the right abstraction and the right stable code that acts on it. Call it fool me once, don’t fool me twice. You aren’t expected to foresee everything, but you are expected to recognize the right abstraction and introduce it the moment the need appears.
Applying the Open-Closed Principle in the real world
Here is a pragmatic checklist for applying OCP in C# and .NET:
- The KISS principle still applies (Keep It Simple, Stupid): don’t underestimate how hard anticipation is, and don’t burn resources on abstractions you won’t need.
- Write automated tests. One of the biggest benefits of testing is that, for a while, you look at your code from the client’s perspective. If some areas are hard to cover, that usually means the code should be refactored to be fully testable. In practice, when you move from poorly testable to 100% testable code, the right abstractions tend to emerge on their own.
- Use code review and static analysis to spot typical OCP violations. Good red flags that you need OCP:
- downcasting a reference (casting from a base class or interface down to a subclass or leaf class),
- using the is or as C# operators to branch on type (as in the first example above),
- NDepend ships the rule Base class should not use derivatives: a match is an obvious OCP violation.
- Keep fool me once, don’t fool me twice in mind: refactor as soon as the need for a new abstraction is identified. Sometimes you can’t, because plenty of client code already depends on your API, and you have to live with the design you have. This is why public API design is so sensitive: you have no choice but to do your best to anticipate, and to accept living with past design mistakes.
Be Open to More Than One Variation with the Visitor Pattern
In the real world, entity objects, like the shapes here, don’t implement algorithms such as drawing. Experience tells us this is itself an OCP violation. As soon as you need a second algorithm on those data objects, say persistence or serialization on top of drawing, every shape class must be modified again. It is also a violation of the Single Responsibility Principle (the S in SOLID), because a shape class now has two responsibilities: (1) model the shape and (2) draw the shape.
So we actually have two variations to absorb: we need to abstract both the shapes and the algorithms applied to them. What we want is to write something like algorithm.ApplyOn(shape). A call that depends on two abstractions at once is called a double dispatch: the implementation invoked depends on both the IShape type and the IAlgorithm type. With N shapes and M algorithms, you have [N x M] implementations to provide.
The Visitor pattern is the classic way to implement double dispatch in C#. With a new persistence algorithm added, the code looks like this:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
// // Shapes elements // public interface IShape { void Accept(IVisitor visitor); } public class Circle : IShape { public void Accept(IVisitor visitor) { visitor.Visit(this); } } public class Square : IShape { public void Accept(IVisitor visitor) { visitor.Visit(this); } } // // Visitors algorithms on shapes elements // don't use the IAlgorithm terminology to keep up with the classical visitor pattern terminology // public interface IVisitor { void Visit(Circle circle); void Visit(Square square); } public class DrawAlgorithm : IVisitor { public void Visit(Circle circle) { /*Draw circle*/} public void Visit(Square square) { /*Draw square*/} } public class PersistAlgorithm : IVisitor { public void Visit(Circle circle) { /*Persist circle*/} public void Visit(Square square) { /*Persist square*/} } public static class Program { public static void ApplyVisitorAlgorithmOnShapesElements( IEnumerable<IShape> shapes, IVisitor visitor) { foreach (IShape shape in shapes) { // Double dispatching: // shape can be both: Circle or Square // visitor can be both Draw or Persist shape.Accept(visitor); } } } |
One caveat worth knowing: the Visitor pattern is closed for adding new algorithms (just write a new IVisitor) but open for modification when you add a new shape, because every existing visitor must then handle it. Pick the dimension you expect to vary most and make that the closed one.
Frequently Asked Questions about the Open-Closed Principle
What is the Open-Closed Principle (OCP) in C#?
The Open-Closed Principle is the “O” in SOLID. It states that a class or module should be open for extension but closed for modification. In C# you achieve this with abstractions, typically an interface or an abstract base class, so that new behavior is added by writing a new class that implements the abstraction, instead of editing existing code.
What does “open for extension, closed for modification” mean?
“Open for extension” means you can add new functionality to the system. “Closed for modification” means you do so without changing the source code that already exists and is already tested. The combination lets the system grow while its stable core stays untouched.
Who came up with the Open-Closed Principle?
Bertrand Meyer introduced the term in his 1988 book Object-Oriented Software Construction. It later became the “O” of the SOLID acronym popularized by Robert C. Martin.
What is a simple OCP example in C#/.NET?
Replacing an if (shape is Circle) … else if (shape is Square) dispatch with an IShape interface that each shape implements. The loop calls shape.Draw() and never changes again, even when you add a Triangle. That refactoring is shown in full above.
How do you detect OCP violations in a .NET codebase?
Watch for type-test branching with the is and as operators and for downcasting from a base type to a derived type. Static analysis helps: NDepend’s rule Base class should not use derivatives (ND1201) flags an obvious category of OCP violations automatically.
Does OCP mean I should never modify code?
No. OCP is about anticipating variation, and anticipation is hard, so follow YAGNI and KISS first. Introduce the abstraction when a real need for variation appears, not before. Speculative abstractions you never use are a cost, not a benefit.
Conclusion
The Open-Closed Principle is one of the most practical ideas in SOLID design: build stable abstractions around the parts of your C# code most likely to change, then add features by writing new classes instead of editing old ones.
- Done well, it keeps your tested code stable and your diffs small.
- Done too early, it just adds abstractions you’ll never use.
So treat OCP as a tool you reach for when a variation actually shows up, lean on tests and static analysis to surface the violations, and refactor the moment the need is real, not a sprint before.
Great! Especially the Visitor pattern.
About IShape interface, I think it must not know anything about draw method. It is not IShape business to know how to draw itself. it is better to get shape geometry like its points in drawer. Then drawing it.
Did you read the end of the article?
“Finally let’s underline that in the real world, data objects (like the shapes here) don’t implement themselves algorithm such as drawing. “
Sorry, I didn’t read that part of the article yet. Beautiful article, thanks.
For me, the Visitor pattern is too much, at least for that particular example. I would rather use some other patter, which is very similar to Strategy pattern:
public static class Drawer {
public static void DrawShapes(IEnumerable shapes, List concreteDrawers)
{
foreach (IShape shape in shapes)
{
foreach (drawer in concreteDrawers)
{
if (drawer.Draw(shape))
break;
}
}
}
}
With visitor example you still have to modify every IVisitor implementation, where on my example provided you just add new class and instantiate new object to append it to the list.