Open/Closed Principle in .NET — without abstract-factory-itis
Open for extension, closed for modification. Add new behavior without editing existing classes.
Most developers learn this and immediately over-apply it — wrapping every concrete decision in three layers of interfaces. The real lesson is more subtle.
The shape the book examples never show
You only need OCP for axes of variation you expect to grow. For everything else, a switch statement is cheaper and easier to read.
Bad — over-abstracted
public interface IDiscountStrategy { decimal Apply(decimal price); }
public class FixedDiscount : IDiscountStrategy { ... }
public class PercentDiscount : IDiscountStrategy { ... }
public class BogoDiscount : IDiscountStrategy { ... }
public interface IDiscountStrategyFactory { IDiscountStrategy Create(DiscountKind kind); }
public class DiscountStrategyFactory : IDiscountStrategyFactory { ... }
Six types to express two discounts. The next dev cannot find where the actual math lives without IDE navigation.
Better — applying OCP only where it pays
public decimal Apply(decimal price, Discount d) => d switch {
Discount.None => price,
Discount.FixedOff x => price - x.Amount,
Discount.PercentOff x => price * (1 - x.Pct),
_ => throw new UnreachableException(),
};
Adding a 4th discount kind requires editing this method — and that is fine. It is one place, the compiler enforces exhaustiveness, the file is 30 lines.
When you DO want the interface version
- New strategies arrive from a plugin (different assembly, different team, different deploy cadence).
- The variants need different dependencies (one calls an HTTP API, another reads a database).
- You ship a rules engine and the count of strategies is genuinely unbounded.
The litmus test
"Will I need to touch this when adding behavior X?"
If yes — and X is likely — abstract. If X is hypothetical, do not.
.NET pattern that gets OCP right
DI registration:
builder.Services.AddKeyedScoped<IPaymentProvider, StripeProvider>("stripe");
builder.Services.AddKeyedScoped<IPaymentProvider, RazorpayProvider>("razorpay");
// Consumer
public class CheckoutService(IServiceProvider sp) {
public Task ChargeAsync(Order o)
=> sp.GetRequiredKeyedService<IPaymentProvider>(o.Gateway).ChargeAsync(o);
}
Adding a third gateway is one new file + one DI line. No existing code changes.