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
switchusually appears again inRefund(),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
IActionFilterfor 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
switchstatements 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-elsechains 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.