Open for extension, closed for modification; strategy, polymorphism, DI and pattern-based extension; when a switch is fine; avoiding speculative generality.
The Open/Closed Principle says modules should be open for extension but closed for modification: you add new behaviour by adding code, not by editing tested code. In C# this usually means a strategy interface with one implementation per variant, registered in DI, so adding a new payment method is a new class rather than another case in a growing switch. I apply it where change actually happens repeatedly. A small, stable switch (or a switch expression over a closed set) is fine; building extension points for variants that never arrive is speculative generality.
"The Open/Closed Principle says software entities should be open for extension but closed for modification: when a new variant of behaviour arrives, you add it as new code instead of editing stable, tested code. In C# that usually means a strategy interface with one implementation per variant, registered in DI (since .NET 8, often as keyed services). Adding payment provider number ten is then a new class plus one registration line, not another
casein a 600-lineswitchthat forces a retest of the other nine. Other mechanisms are polymorphism, decorators, ASP.NET Core middleware and filters, events, and data-driven rules. The part people miss is that a module can only be closed against the kinds of change you anticipated, so OCP is a bet. I place it where change has actually repeated, typically at the second or third similar edit. A small, closedswitchexpression over an enum is perfectly fine, and building plug-in frameworks for variants that never arrive is speculative generality."
| Without OCP | With OCP (on the right axis) |
|---|---|
| Every new variant edits the same method | New variant = new type |
| Retest all variants each time | Test the new variant; others untouched |
| Merge conflicts in one hot file | Changes land in separate files |
| Risk of breaking unrelated cases | Existing behaviour is closed |
| Mechanism | Example | Good for |
|---|---|---|
| Strategy interface + DI | IPaymentProvider per provider |
Growing set of variants |
| Keyed services (.NET 8+) | AddKeyedScoped<IPaymentProvider, StripeProvider>("stripe") |
Select implementation by key |
IEnumerable<T> injection |
All IDiscountRules applied in turn |
Rule sets, pipelines |
| Polymorphism | abstract Shape.Area() |
Behaviour tied to type |
| Decorators | Caching/logging around a service | Adding behaviour around existing code |
| Middleware / endpoint filters | Auth, logging, validation | Request pipeline extensions |
| Domain events / handlers | OrderPlaced → new handler |
Side effects added independently |
| Data-driven rules | Discount tables, feature flags | Business-editable variation |
switch is the right answer| Situation | Use |
|---|---|
| Small, closed set (enum that rarely changes) | switch expression |
| Many operations over a fixed set of types | Pattern matching (switch on types) |
| Simple, local behaviour | switch |
| New variants keep arriving | Strategy/polymorphism |
The same switch repeated in several places |
Polymorphism or one strategy lookup |
Exhaustive switch expressions (with nullable/enum warnings) make adding a case a compiler-guided change.
| Sign of over-application | Better |
|---|---|
| Interface with one implementation for years | Plain class; extract an interface when a second variant appears |
| Plug-in loading for features nobody plugs in | Normal classes in the codebase |
| Configuration options nobody sets | Remove them |
| Extension points chosen by imagination | Extension points chosen by change history |
Our [checkout service] had a [600-line] switch over [nine] payment methods. Each new provider took [about two weeks] including regression testing of every case, and two incidents came from edits that accidentally changed unrelated providers. Git history showed the switch touched for every provider, so this was clearly the axis of change. I introduced IPaymentProvider, moved each case into its own class, and registered them as keyed services (AddKeyedScoped<IPaymentProvider, X>("key")), with Checkout resolving by key. I added contract tests every provider must pass. Provider ten became a new class plus one registration line, existing providers stopped breaking, and the lead time for a new provider dropped to [a few days].
Checks balanced understanding of OCP.
Green flags
Red flags
| Version | Idea |
|---|---|
| Bertrand Meyer (1988) | Extend via inheritance: closed classes, new subclasses for change |
| Polymorphic OCP (Martin, 1990s) | Depend on abstractions; new implementations plug in |
| Modern practice | Composition, DI, strategies, decorators, pipelines, events |
No design is closed against every change. Choose the axis you observe:
Axis of change seen in history Closure mechanism
new payment providers IPaymentProvider strategies
new export formats IExporter keyed by format
new discount types IEnumerable<IDiscountRule>
new cross-cutting behaviour decorators / middleware
If the business instead starts changing how every provider is called (new step for all), your strategy interface itself changes; that's normal. Re-evaluate the axis.
builder.Services.AddKeyedScoped<IPaymentProvider, StripeProvider>("stripe");
builder.Services.AddKeyedScoped<IPaymentProvider, PayPalProvider>("paypal");
// Inject a specific one
public sealed class RefundService([FromKeyedServices("stripe")] IPaymentProvider stripe) { }
// Or resolve by runtime key
var provider = sp.GetRequiredKeyedService<IPaymentProvider>(method);
Before .NET 8, the usual pattern was IEnumerable<IPaymentProvider> with a Name property turned into a dictionary, or a factory.
| You add mostly... | Easy with | Hard with |
|---|---|---|
| New types/variants | Polymorphism (new class) | Switches (edit every switch) |
| New operations on a fixed set | Pattern matching (new function) | Polymorphism (edit every class) |
That's why both styles are legitimate in C#. Discriminated-union-style records plus switch expressions suit closed hierarchies; interfaces suit open ones.
Checkout doesn't reference concrete providers.| Smell | Meaning |
|---|---|
Same switch on a type in many files |
Missing polymorphism |
if (customer == "Acme") in core logic |
Missing configuration/extension point |
| Every feature edits one "central" class | Wrong axis of closure |
| Interface with a single implementation for years | Speculative extension point (the opposite smell) |
switch computing shipping cost for three carriers; add a fourth and list files and tests touched.IShippingCalculator keyed services; add a fifth and compare.using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddKeyedSingleton<IPaymentProvider, CardProvider>("card");
services.AddKeyedSingleton<IPaymentProvider, PayIdProvider>("payid"); // new provider: new class + one line
services.AddSingleton<Checkout>();
using var sp = services.BuildServiceProvider();
var checkout = sp.GetRequiredService<Checkout>();
Console.WriteLine(await checkout.PayAsync("payid", 49.95m));
Console.WriteLine(GstRates.Rate(Gst.Standard));
public interface IPaymentProvider
{
Task<string> ChargeAsync(decimal amount, CancellationToken ct = default);
}
public sealed class CardProvider : IPaymentProvider
{
public Task<string> ChargeAsync(decimal amount, CancellationToken ct = default) =>
Task.FromResult($"Card charged {amount:C}");
}
public sealed class PayIdProvider : IPaymentProvider
{
public Task<string> ChargeAsync(decimal amount, CancellationToken ct = default) =>
Task.FromResult($"PayID request sent for {amount:C}");
}
// Closed for modification: adding a provider does not change this class.
public sealed class Checkout(IServiceProvider services)
{
public Task<string> PayAsync(string method, decimal amount, CancellationToken ct = default) =>
services.GetRequiredKeyedService<IPaymentProvider>(method).ChargeAsync(amount, ct);
}
// A closed set where a switch expression is clearer than a strategy per case.
public enum Gst { Standard, Free, InputTaxed }
public static class GstRates
{
public static decimal Rate(Gst g) => g switch
{
Gst.Standard => 0.10m,
Gst.Free or Gst.InputTaxed => 0m,
_ => throw new ArgumentOutOfRangeException(nameof(g))
};
}
Needs the Microsoft.Extensions.DependencyInjection package (8.0 or later for keyed services). Top-level await makes the entry point async, so PayAsync is awaited correctly.
What happens: interfaces and plug-in loaders for variants that may never exist. Why it is wrong: indirection and maintenance with no benefit; often the wrong axis. Do instead: introduce the extension point when the second or third real variant arrives.
switch duplicated across the codebaseWhat happens: switch (order.Type) in pricing, shipping, invoicing and emails.
Why it is wrong: every new type must be added everywhere; some places get missed.
Do instead: move behaviour into polymorphic types or one strategy lookup.
What happens: teams add wrappers instead of fixing bugs. Why it is wrong: bug fixes and refactoring require edits. Do instead: aim to add new variants without modifying stable logic.
What happens: if (tenant == "Acme") ... scattered through services.
Why it is wrong: core code becomes a list of exceptions.
Do instead: configuration, tenant-specific strategies or rules data.
What happens: Checkout checks if (provider is StripeProvider s) s.SpecialThing().
Why it is wrong: the caller is no longer closed; it changes per variant.
Do instead: express needed behaviour in the interface, or rethink the abstraction.
What happens: every new operation means editing every subclass. Why it is wrong: wrong side of the expression problem. Do instead: switch expressions / pattern matching for fixed sets.
What happens: IPaymentProvider with 25 members, half not supported by some providers.
Why it is wrong: implementers throw NotSupportedException; violates ISP and LSP.
Do instead: small, focused interfaces; optional capabilities as separate interfaces.
newWhat happens: a factory with new StripeProvider() inside a switch.
Why it is wrong: the switch just moved; adding providers still edits it.
Do instead: resolve from DI (keyed services, IEnumerable<T>).
What happens: each provider is tested differently. Why it is wrong: inconsistent behaviour breaks substitutability. Do instead: one shared contract test suite for all implementations.
What happens: logging/caching is added by editing every service. Why it is wrong: modifies stable code for cross-cutting behaviour. Do instead: wrap with decorators, middleware or filters.
OCP is valuable along the axes where change really happens. I look for repeated edits to the same switch or method, and I introduce the extension point then, not before.
| Situation | Choice |
|---|---|
| Variants keep being added | Strategy interface + DI (keyed services) |
| Fixed set, many operations | Switch expressions, pattern matching |
| Cross-cutting behaviour | Decorators, middleware, filters |
| Per-tenant rules | Configuration or rules data |
| Only one variant | Plain class, no interface yet |
SRP says a module should have one reason to change, which Robert C. Martin later phrased as "responsible to one actor": one group of people whose requests drive its changes. It is not "a class does one thing" or "one method per class". In .NET I look for classes that change for unrelated reasons, such as an OrderService that holds pricing rules (finance), email templates (marketing) and SQL (DBA), and split along those change drivers. I stop splitting when the pieces would always change together, because that is just scattering one responsibility across files.
LSP says that code written against a base type must keep working when given any subtype, without knowing which one. Subtypes must not strengthen preconditions, weaken postconditions or break the base type's invariants, and should not throw new kinds of exceptions for normal use. Classic violations: a ReadOnlyList deriving from a mutable list and throwing NotSupportedException on Add, or Square : Rectangle breaking independent width and height. Detect it with is/type checks in callers, NotSupportedException overrides and contract tests that fail for some subtypes. Fix by redesigning the hierarchy: smaller interfaces, composition, or separate types.
Choose an interface when you are defining a capability or contract that unrelated types may implement, when a type may need several such contracts, when structs must implement it, or when you need static abstract members for generic code. Choose an abstract class when implementers share state, constructor logic or a fixed algorithm, and when the type is an extension point that will grow, because you can add virtual members with bodies safely. Default interface methods narrowed the gap but cannot hold state, so they are mainly for evolving published interfaces. In libraries, a common pattern is both: consumers depend on the interface, and implementers can derive from an abstract base that does the plumbing.
How to tell it (STAR)
Our checkout had one giant switch for [nine] payment methods, and every new provider meant editing and retesting all of it. I moved each provider behind an interface with keyed DI registration. Provider ten was a new class and one registration line, and we stopped breaking existing providers.
Last updated 31 Oct 2026.