📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is the Open/Closed Principle, and how do you apply it in C# without building speculative plug-in frameworks?

Open for extension, closed for modification; strategy, polymorphism, DI and pattern-based extension; when a switch is fine; avoiding speculative generality.

Mid-Level Architecture Intermediate design-principles ocp solid strategy-pattern

🎯 Key points

  • Open for extension, closed for modification
  • Add variants as new code rather than editing stable logic
  • Typical tools: strategy interfaces with DI, polymorphism, decorators, middleware
  • Keyed services (.NET 8+) select implementations by key
  • A small closed switch expression is often clearer
  • Introduce extension points when repeated change appears, not speculatively

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

"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 case in a 600-line switch that 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, closed switch expression over an enum is perfectly fine, and building plug-in frameworks for variants that never arrive is speculative generality."


1. What OCP protects

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

2. C# mechanisms

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

3. When a 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.

4. Avoiding speculative generality

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

Production example

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].

🔍 What the interviewer is checking

Checks balanced understanding of OCP.

Green flags

  • Explains extension without modification with a real C# mechanism.
  • Knows when a switch is fine.
  • Warns about speculative generality.
  • Mentions keyed services or DI-based strategies.

Red flags

  • Interfaces for everything "for OCP".
  • Cannot give a concrete refactoring.

📚 Learn in depth

1. Origins

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

2. "Closed against what?"

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.

3. Keyed services (.NET 8+)

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.

4. The expression problem

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.

5. OCP and testing

  • Contract tests: one shared test suite that every implementation must pass (also enforces Liskov).
  • New variants are tested alone; existing tests remain green without modification.
  • Architecture tests (e.g. NetArchTest) can enforce that Checkout doesn't reference concrete providers.

6. Smells pointing to a missing extension point

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)

7. Try it yourself

  1. Write a switch computing shipping cost for three carriers; add a fourth and list files and tests touched.
  2. Refactor to IShippingCalculator keyed services; add a fifth and compare.
  3. Write a contract test base class and run it against every carrier.
  4. Model a closed set (GST categories) with a switch expression and enable warnings for unhandled enum values.
  5. Find an interface in your codebase with one implementation; decide whether it earns its keep.

💻 Code

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.

⚠ Common mistakes

1. Speculative plug-in points

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.

2. The same switch duplicated across the codebase

What 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.

3. Reading OCP as "never edit existing code"

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.

4. Customer-specific branches in core 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.

5. Leaking variant knowledge into the caller

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.

6. Choosing polymorphism for a closed set with many operations

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.

7. Giant "extensible" interfaces

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.

8. Registering strategies but resolving with new

What 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>).

9. No contract tests for implementations

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.

10. Forgetting decorators and middleware as OCP tools

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.

🏛 Senior insight: trade-offs

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

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says each provider class has one job Why: Links OCP to SRP
    How to answer

    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.

  2. When: Candidate relies on substituting providers Why: Checks every implementation honours the contract
    How to answer

    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.

  3. When: Candidate chooses an interface for the strategy Why: Probes interface versus abstract class choice
    How to answer

    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.

🛠 Real-world problems: how you handle them

Payment switch replaced with provider strategies
Situation
A [checkout service] had a [600-line] switch over [nine] payment methods.
Signals
Each new provider took [two weeks] including regression testing; two incidents came from edits to unrelated cases.
Investigation
Git history showed the switch edited for every provider; tests for all providers had to be rerun each time.
Resolution
Introduced `IPaymentProvider`, moved each case to its own class and registered them as keyed services.
Prevention
Contract tests that every provider must pass, and a template for new providers.

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.

↩ Usually asked after

More in SOLID Principles

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me