Skip to content

Open/Closed Principle (OCP)

Definition: "Software entities (classes, modules, functions) should be open for extension, but closed for modification." — Bertrand Meyer, 1988

In simple words: when a new requirement comes, you should be able to add new code instead of changing code that already works and is already tested.

  • Open for extension: you can add new behaviour (a new payment method, a new report format).
  • Closed for modification: you don't need to edit the existing, tested class to do it.

Real-life example

Your laptop has USB ports. When you buy a new mouse, a pen drive or a webcam, you just plug it in. You don't open the laptop and solder new wires. The laptop is closed for modification but open for extension through the USB "interface".

The problem: a growing switch statement

An Indian e-commerce site starts with UPI and cards. Then the business asks for net banking, then wallets, then "Buy Now Pay Later", then international PayPal...

public class PaymentService
{
    public PaymentResult Pay(string paymentMode, decimal amount, string orderId)
    {
        switch (paymentMode)
        {
            case "UPI":
                // 40 lines: call the UPI provider API, verify signature...
                return new PaymentResult(true, "UPI-REF-1234");

            case "Card":
                // 50 lines: tokenise card, 3-D Secure, call card gateway...
                return new PaymentResult(true, "CARD-REF-5678");

            case "NetBanking":
                // 45 lines: redirect to bank...
                return new PaymentResult(true, "NB-REF-9012");

            // Next sprint: "Wallet". Sprint after: "PayLater". Then "PayPal"...

            default:
                throw new NotSupportedException($"Payment mode {paymentMode} not supported");
        }
    }
}

Why is this a problem?

  • Every new payment method means opening and editing this class, which handles real money.
  • A small mistake while adding "Wallet" can break UPI payments that were working perfectly.
  • The class grows to thousands of lines, and every change needs the whole payment flow to be retested.
  • Two developers adding two gateways at the same time get merge conflicts in the same file.
  • The same switch usually appears again in Refund(), GetStatus() and so on, so a new gateway means editing several places.

The solution: an abstraction plus one class per gateway

Step 1: Define the extension point

public interface IPaymentGateway
{
    string PaymentMode { get; }   // "UPI", "Card", ...
    Task<PaymentResult> PayAsync(decimal amount, string orderId);
}

public record PaymentResult(bool Success, string TransactionId);

Step 2: One class per payment method

public class UpiGateway : IPaymentGateway
{
    public string PaymentMode => "UPI";

    public async Task<PaymentResult> PayAsync(decimal amount, string orderId)
    {
        // call the UPI provider's API here
        await Task.Delay(100);
        return new PaymentResult(true, $"UPI-{orderId}");
    }
}

public class CardGateway : IPaymentGateway
{
    public string PaymentMode => "Card";

    public async Task<PaymentResult> PayAsync(decimal amount, string orderId)
    {
        // tokenise the card, 3-D Secure, call the card gateway
        await Task.Delay(100);
        return new PaymentResult(true, $"CARD-{orderId}");
    }
}

Step 3: PaymentService is now closed for modification

ASP.NET Core can inject all registered implementations of an interface as IEnumerable<T>. The service picks the right one by its PaymentMode.

public class PaymentService
{
    private readonly Dictionary<string, IPaymentGateway> _gateways;

    public PaymentService(IEnumerable<IPaymentGateway> gateways)
    {
        _gateways = gateways.ToDictionary(g => g.PaymentMode, StringComparer.OrdinalIgnoreCase);
    }

    public Task<PaymentResult> PayAsync(string paymentMode, decimal amount, string orderId)
    {
        if (!_gateways.TryGetValue(paymentMode, out IPaymentGateway? gateway))
            throw new NotSupportedException($"Payment mode {paymentMode} not supported");

        return gateway.PayAsync(amount, orderId);
    }
}

Step 4: Register in Program.cs

builder.Services.AddScoped<IPaymentGateway, UpiGateway>();
builder.Services.AddScoped<IPaymentGateway, CardGateway>();
builder.Services.AddScoped<PaymentService>();

Step 5: The business asks for wallets

We add one new class and one registration line. PaymentService, UpiGateway and CardGateway are not touched, so they don't need to be retested.

public class WalletGateway : IPaymentGateway
{
    public string PaymentMode => "Wallet";

    public async Task<PaymentResult> PayAsync(decimal amount, string orderId)
    {
        await Task.Delay(100);   // call the wallet provider
        return new PaymentResult(true, $"WAL-{orderId}");
    }
}

// Program.cs: one new line
builder.Services.AddScoped<IPaymentGateway, WalletGateway>();

This design is the Strategy pattern: each gateway is a strategy, and PaymentService chooses one at runtime. Strategy is the most common way to apply OCP.

Second example: discount rules

Marketing changes discount rules all the time: festival sales, first-order offers, gold members. Instead of an if/else chain, each rule is a class:

public interface IDiscountRule
{
    decimal GetDiscount(Order order);
}

public class GoldMemberDiscount : IDiscountRule
{
    public decimal GetDiscount(Order order) =>
        order.Customer.IsGoldMember ? order.SubTotal * 0.10m : 0;
}

public class FirstOrderDiscount : IDiscountRule
{
    public decimal GetDiscount(Order order) =>
        order.Customer.OrderCount == 0 ? 200 : 0;
}

public class DiscountCalculator
{
    private readonly IEnumerable<IDiscountRule> _rules;

    public DiscountCalculator(IEnumerable<IDiscountRule> rules) => _rules = rules;

    // the best single discount wins
    public decimal Calculate(Order order) =>
        _rules.Select(r => r.GetDiscount(order)).DefaultIfEmpty(0).Max();
}

A "Diwali sale" next month is a new DiwaliDiscount class plus one line in Program.cs. When the sale ends, remove the registration line.

Alternative in .NET 8: keyed services

From .NET 8 you can register implementations with a key and ask for one directly:

builder.Services.AddKeyedScoped<IPaymentGateway, UpiGateway>("UPI");
builder.Services.AddKeyedScoped<IPaymentGateway, CardGateway>("Card");

// resolve by key at runtime
var gateway = serviceProvider.GetRequiredKeyedService<IPaymentGateway>(paymentMode);

Read more in Keyed Services Dependency Injection.

OCP in ASP.NET Core itself

  • Middleware: you add authentication, CORS or custom exception handling with app.UseXxx(), without modifying the framework's request pipeline code.
  • Filters: add an IActionFilter for auditing without changing your controllers.
  • Logging providers: plug in Serilog or Application Insights without changing any ILogger<T> call.

Common mistakes

  • Abstracting everything up front. If there is only one way of doing something and no sign of change, a simple class is fine. A practical rule: when the second or third variation appears, refactor to OCP.
  • Thinking "closed" means "never edit". Bug fixes are always allowed. OCP is about adding new features without editing working code.
  • Moving the switch somewhere else. A factory full of switch statements still needs editing. Let DI collect the implementations, as shown above.

Summary

  • Open for extension, closed for modification: add new classes, don't edit working ones.
  • Replace switch/if-else chains on a "type" with an interface and one class per case.
  • In ASP.NET Core, inject IEnumerable<T> or use keyed services to pick the implementation.
  • The Strategy pattern is the most common way to apply OCP.
  • Apply it where change is expected: payment gateways, discounts, notifications, export formats.