TL;DR:

  • The Open/Closed Principle (OCP) states: classes should be open for extension, but closed for modification.
  • You don’t need to create a plugin for every new feature, favor simpler patterns like strategy, inheritance, or composition.
  • Start with well-named abstractions; introduce extensibility points only when real change is expected.
  • Keep code maintainable by balancing extension points with simplicity. Overengineering is a bigger risk than occasional refactoring.

The Open/Closed Principle isn’t about making everything extensible. It simply means that your core business logic should be closed to modification but open to extension. The real skill is knowing when to apply it and when you’re just over-engineering your code.


flowchart TD
    subgraph "OCP Violation"
        V_Start[PaymentProcessor]
        V_Change[Add New Payment Method]
        V_Modify[Modify Existing Code]
        V_Switch[Growing Switch Statement]
        V_Test[Retest Everything]
        V_Deploy[Redeploy Entire App]
        
        V_Start --> V_Change
        V_Change --> V_Modify
        V_Modify --> V_Switch
        V_Switch --> V_Test
        V_Test --> V_Deploy
        
        classDef violation fill:#ffcccc,stroke:#ff0000
        class V_Start,V_Change,V_Modify,V_Switch,V_Test,V_Deploy violation
    end
    
    subgraph "OCP Compliance"
        C_Start[PaymentProcessor + IPaymentHandler]
        C_Add[Add New Payment Method]
        C_Create[Create New Handler Class]
        C_Register[Register in DI Container]
        C_Deploy[Deploy Only New Code]
        
        C_Start --> C_Add
        C_Add --> C_Create
        C_Create --> C_Register
        C_Register --> C_Deploy
        
        classDef compliance fill:#ccffcc,stroke:#00aa00
        class C_Start,C_Add,C_Create,C_Register,C_Deploy compliance
    end
    
    BadPractice[❌ BAD: Must modify existing code]
    GoodPractice[✅ GOOD: Extend without modifying]
    
    V_Switch -.-> BadPractice
    C_Create -.-> GoodPractice

    

Open/Closed Principle: Code Evolution Patterns

The Problem: Switch Statements in Business Logic

Here’s a payment processor that violates OCP:

public class PaymentProcessor
{
    public async Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request)
    {
        switch (request.PaymentMethod)
        {
            case PaymentMethod.CreditCard:
                return await ProcessCreditCardAsync(request);
            
            case PaymentMethod.PayPal:
                return await ProcessPayPalAsync(request);
            
            case PaymentMethod.BankTransfer:
                return await ProcessBankTransferAsync(request);
            
            default:
                throw new NotSupportedException($"Payment method {request.PaymentMethod} not supported");
        }
    }
    
    private async Task<PaymentResult> ProcessCreditCardAsync(PaymentRequest request)
    {
        // Credit card processing logic
        // Every new payment method requires modifying this class
    }
}

Adding a new payment method requires modifying the PaymentProcessor class, violating OCP.

The Solution: Polymorphism for Extension

public interface IPaymentHandler
{
    PaymentMethod PaymentMethod { get; }
    Task<PaymentResult> ProcessAsync(PaymentRequest request);
}

public class CreditCardHandler : IPaymentHandler
{
    public PaymentMethod PaymentMethod => PaymentMethod.CreditCard;
    
    public async Task<PaymentResult> ProcessAsync(PaymentRequest request)
    {
        // Credit card specific logic
        await Task.Delay(100); // Simulate API call
        return new PaymentResult { Success = true, TransactionId = Guid.NewGuid().ToString() };
    }
}

public class PayPalHandler : IPaymentHandler
{
    public PaymentMethod PaymentMethod => PaymentMethod.PayPal;
    
    public async Task<PaymentResult> ProcessAsync(PaymentRequest request)
    {
        // PayPal specific logic
        await Task.Delay(200); // Simulate API call
        return new PaymentResult { Success = true, TransactionId = Guid.NewGuid().ToString() };
    }
}
public class PaymentProcessor
{
    private readonly Dictionary<PaymentMethod, IPaymentHandler> _handlers;
    
    public PaymentProcessor(IEnumerable<IPaymentHandler> handlers)
    {
        _handlers = handlers.ToDictionary(h => h.PaymentMethod, h => h);
    }
    
    public async Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request)
    {
        if (!_handlers.TryGetValue(request.PaymentMethod, out var handler))
            throw new NotSupportedException($"Payment method {request.PaymentMethod} not supported");
        
        return await handler.ProcessAsync(request);
    }
}

OCP in a Nutshell: Before and After

Before OCPAfter OCP
Switch statements or if-else chainsPolymorphism with interfaces
Modifying existing code to add featuresCreating new classes to add features
Higher risk of regression bugsIsolated changes with minimal impact
Hard to test in isolationEasy to test new components
Single, growing classMultiple focused classes with clear responsibilities

Modern C# 12 and .NET 8 Enhancements

Let’s update our solution with the latest C# features:

// Using C# 12 primary constructor and collection expressions
public class PaymentProcessor(IEnumerable<IPaymentHandler> handlers)
{
    private readonly Dictionary<PaymentMethod, IPaymentHandler> _handlers = 
        handlers.ToDictionary(h => h.PaymentMethod);
    
    // Using required properties for guaranteed initialization
    public required ILogger Logger { get; init; }
    
    public async Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request)
    {
        try
        {
            if (!_handlers.TryGetValue(request.PaymentMethod, out var handler))
            {
                Logger.LogWarning("Unsupported payment method: {Method}", request.PaymentMethod);
                throw new NotSupportedException($"Payment method {request.PaymentMethod} not supported");
            }
            
            Logger.LogInformation("Processing {Method} payment", request.PaymentMethod);
            return await handler.ProcessAsync(request);
        }
        catch (Exception ex) when (ex is not NotSupportedException)
        {
            Logger.LogError(ex, "Payment processing error");
            throw;
        }
    }
}

// Using the new .NET 8 TimeProvider for better testability
public class CryptoCurrencyHandler(TimeProvider timeProvider, ICryptoRatesService ratesService) : IPaymentHandler
{
    public PaymentMethod PaymentMethod => PaymentMethod.Crypto;
    
    public async Task<PaymentResult> ProcessAsync(PaymentRequest request)
    {
        // Using pattern matching with property patterns
        if (request is not { CryptoDetails.WalletAddress: { Length: > 0 } wallet })
        {
            return new PaymentResult 
            { 
                Success = false, 
                ErrorMessage = "Invalid wallet address" 
            };
        }
        
        var currentRate = await ratesService.GetCurrentRateAsync(request.CryptoDetails.Currency);
        var timestamp = timeProvider.GetUtcNow();
        
        // Process the crypto payment...
        
        return new PaymentResult 
        { 
            Success = true, 
            TransactionId = Guid.NewGuid().ToString(),
            ProcessedAt = timestamp 
        };
    }
}

Registration with .NET 8 Dependency Injection

// Program.cs
var builder = WebApplication.CreateBuilder(args);

// Register all payment handlers automatically
builder.Services.AddKeyedSingleton<ITimeProvider, SystemTimeProvider>(ServiceLifetime.Singleton);
builder.Services.Scan(scan => scan
    .FromAssemblyOf<IPaymentHandler>()
    .AddClasses(classes => classes.AssignableTo<IPaymentHandler>())
    .AsImplementedInterfaces()
    .WithScopedLifetime());

// Register the payment processor
builder.Services.AddScoped<PaymentProcessor>(sp =>
{
    var handlers = sp.GetRequiredService<IEnumerable<IPaymentHandler>>();
    var logger = sp.GetRequiredService<ILogger<PaymentProcessor>>();
    
    return new PaymentProcessor(handlers) 
    { 
        Logger = logger 
    };
});

var app = builder.Build();
// ... other app configuration

When NOT to Apply OCP

Over-AbstractionGood OCP
Making every string configurableAbstracting payment processing
Creating interfaces for DTOsUsing strategy pattern for algorithms
Polymorphism for static dataExtension points for business rules
Plugin architecture for CRUDHandlers for complex workflows

When to Use OCP

Use OCP when you have:

  • Business rules that change often (like tax calculations or pricing rules)
  • Different ways to do the same thing (like payment methods or notification types)
  • Algorithms you need to swap out while your app is running

Skip it for:

  • Basic data transformations
  • Settings you set once and forget
  • Code that rarely changes

A Simpler Real-World Example: Notification System

Here’s a concise example of applying OCP to a notification system:

// Before OCP: Violation with hard-coded notification types
public class NotificationService
{
    public void SendNotification(string message, string recipient, string type)
    {
        switch (type)
        {
            case "email":
                SendEmail(message, recipient);
                break;
            case "sms":
                SendSms(message, recipient);
                break;
            // To add a new notification type (e.g., "push"), we'd have to modify this method
            default:
                throw new ArgumentException($"Unknown notification type: {type}");
        }
    }
    
    private void SendEmail(string message, string emailAddress) { /* Email sending logic */ }
    private void SendSms(string message, string phoneNumber) { /* SMS sending logic */ }
}

// After OCP: Using interfaces and dependency injection
public interface INotifier
{
    string Type { get; }
    Task SendAsync(string message, string recipient);
}

public class EmailNotifier : INotifier
{
    public string Type => "email";
    
    public Task SendAsync(string message, string recipient) 
    { 
        // Email sending logic
        Console.WriteLine($"Sending email to {recipient}: {message}");
        return Task.CompletedTask;
    }
}

public class SmsNotifier : INotifier
{
    public string Type => "sms";
    
    public Task SendAsync(string message, string recipient) 
    { 
        // SMS sending logic
        Console.WriteLine($"Sending SMS to {recipient}: {message}");
        return Task.CompletedTask;
    }
}

// Adding a new notification type requires zero changes to existing code:
public class PushNotifier : INotifier
{
    public string Type => "push";
    
    public Task SendAsync(string message, string recipient) 
    { 
        // Push notification logic
        Console.WriteLine($"Sending push notification to {recipient}: {message}");
        return Task.CompletedTask;
    }
}

// The service that follows OCP
public class NotificationService(IEnumerable<INotifier> notifiers)
{
    private readonly Dictionary<string, INotifier> _notifiers = 
        notifiers.ToDictionary(n => n.Type, StringComparer.OrdinalIgnoreCase);
    
    public async Task SendNotificationAsync(string message, string recipient, string type)
    {
        if (!_notifiers.TryGetValue(type, out var notifier))
            throw new ArgumentException($"Unknown notification type: {type}");
        
        await notifier.SendAsync(message, recipient);
    }
}

This simple example shows why OCP is useful: you can add new notification types without changing any existing code.

Real-World Example: Document Processing Pipeline

Consider a document processing system that needs to handle multiple file formats (PDF, Word, Excel, etc.) and perform various operations on them:

// Without OCP - a file processor with switch statements
public class DocumentProcessor
{
    public void ProcessDocument(string filePath)
    {
        var extension = Path.GetExtension(filePath).ToLowerInvariant();
        
        switch (extension)
        {
            case ".pdf":
                ProcessPdf(filePath);
                break;
            case ".docx":
                ProcessWord(filePath);
                break;
            case ".xlsx":
                ProcessExcel(filePath);
                break;
            default:
                throw new NotSupportedException($"Unsupported file type: {extension}");
        }
    }
    
    private void ProcessPdf(string filePath) { /* PDF processing */ }
    private void ProcessWord(string filePath) { /* Word processing */ }
    private void ProcessExcel(string filePath) { /* Excel processing */ }
}

// With OCP - a document processing pipeline with handlers
public interface IDocumentHandler
{
    bool CanHandle(string filePath);
    Task ProcessAsync(string filePath, DocumentContext context);
}

public class PdfHandler : IDocumentHandler
{
    public bool CanHandle(string filePath) => 
        Path.GetExtension(filePath).Equals(".pdf", StringComparison.OrdinalIgnoreCase);
    
    public async Task ProcessAsync(string filePath, DocumentContext context)
    {
        // PDF-specific processing
        await Task.CompletedTask; // Placeholder
    }
}

// Using .NET 8 file handling improvements
public class DocumentProcessingPipeline(IEnumerable<IDocumentHandler> handlers, ILogger<DocumentProcessingPipeline> logger)
{
    public async Task ProcessAsync(string filePath, DocumentContext context)
    {
        // Find appropriate handler
        var handler = handlers.FirstOrDefault(h => h.CanHandle(filePath));
        
        if (handler == null)
        {
            var extension = Path.GetExtension(filePath);
            logger.LogError("No handler found for {Extension}", extension);
            throw new NotSupportedException($"Unsupported file type: {extension}");
        }
        
        logger.LogInformation("Processing {FilePath} with {Handler}", filePath, handler.GetType().Name);
        await handler.ProcessAsync(filePath, context);
    }
}

Common OCP Mistakes

  1. Too much abstraction too soon - Adding flexibility before you actually need it
  2. Over-complicating things - Making everything pluggable when most code won’t change anyway
  3. Using too many if/switch statements - The classic sign you’re violating OCP
  4. Missing the point of OCP - It’s a tool for better code, not a goal by itself

Tips for Using OCP Right

  1. Wait for the second case - Don’t create abstractions until you actually have at least two versions
  2. Watch for warning signs like files that keep changing for similar reasons
  3. Prefer composition over inheritance when you can
  4. Try feature flags for simpler variations
  5. Use the “rule of three” - When you see the same pattern three times, that’s when you should consider an abstraction

The Practical Payoff

With OCP, adding new payment methods or document handlers becomes a matter of adding new classes, not modifying existing ones. This leads to:

  • Reduced regression risks - Existing code remains untouched
  • Parallel development - Teams can work on new handlers without conflicts
  • Testability - Each handler can be tested in isolation
  • Clean code - Business logic isn’t buried in switch statements

The goal is extensibility without complexity. New payment methods can be added without touching existing code, but you haven’t over-engineered simple operations.

OCP’s Connection to Other SOLID Principles

OCP works better when you use it with other SOLID principles:

  1. Single Responsibility Principle (SRP) - OCP is easier to implement when classes do just one thing well. The PaymentProcessor just routes payments to handlers, while each handler focuses on its specific payment method.

  2. Liskov Substitution Principle (LSP) - All payment handlers share the same interface and can be swapped without breaking anything.

  3. Interface Segregation Principle (ISP) - By keeping interfaces like IPaymentHandler small and focused, implementations don’t have to support methods they don’t need.

  4. Dependency Inversion Principle (DIP) - The PaymentProcessor depends on abstractions (IPaymentHandler), not specific implementations, which makes it easier to extend.

When you apply all SOLID principles together, you end up with code that’s:

  • Modular - You can build and test each piece on its own
  • Extensible - You can add new stuff without breaking what’s already there
  • Maintainable - Changes don’t cause unexpected problems elsewhere
  • Testable - You can easily replace real dependencies with test doubles

OCP isn’t just about making your code extensible, it’s about making changes less scary and more predictable.