Mert Özen Further With Every Line
Software Design SOLID and Design Principles — #04

Open/Closed Principle: Extending Without Modifying and the Speculative Generality Trap

Mert Özen Nov 7, 2026 12 min 2 views
Open/Closed Principle: Extending Without Modifying and the Speculative Generality Trap

OCP means adding new behavior as a new implementation without opening existing, working code; but any code can only be closed against a particular axis of change. Closing a growing notification switch with INotificationChannel and .NET 8 keyed services, HttpClient's DelegatingHandler chain, lightweight extension points with Func, and the cost of speculative generality.

Version note: The code in this post was built and run with C# 14 and .NET 10.

Guess First

The notification code from the first post of the series has grown. Marketing asked for push notifications; the developer added Push to the enum, wrote a new case in the switch, saw the notification arrive on Grace's phone and closed the ticket. The build succeeds. Further down the same class there is another method that the billing team added months ago. Does this program run to the end?

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

Customer[] customers =
[
    new(1, "Ada", NotificationChannel.Email),
    new(2, "Alan", NotificationChannel.Sms),
    new(3, "Grace", NotificationChannel.Push)
];

NotificationService notifications = new();
foreach (Customer customer in customers)
    notifications.OrderConfirmed(customer, orderId: 1000 + customer.Id);

Console.WriteLine($"Cost: {notifications.TotalCost} EUR");

// Push was added yesterday
public enum NotificationChannel { Email, Sms, Push }

public sealed record Customer(int Id, string Name, NotificationChannel PreferredChannel);

public sealed class NotificationService
{
    public decimal TotalCost { get; private set; }

    public void OrderConfirmed(Customer customer, int orderId)
    {
        string message = $"Order {orderId} confirmed";
        switch (customer.PreferredChannel)
        {
            case NotificationChannel.Email:
                Console.WriteLine($"[email] {customer.Name}: {message}");
                break;
            case NotificationChannel.Sms:
                Console.WriteLine($"[sms] {customer.Name}: {message}");
                break;
            case NotificationChannel.Push:
                Console.WriteLine($"[push] {customer.Name}: {message}");
                break;
        }

        TotalCost += CostOf(customer.PreferredChannel);
    }

    // A second switch added months ago for billing
    private static decimal CostOf(NotificationChannel channel) => channel switch
    {
        NotificationChannel.Email => 0.001m,
        NotificationChannel.Sms => 0.05m
    };
}

The Compiler Warned, the Build Didn't Break

Output:

[email] Ada: Order 1001 confirmed
[sms] Alan: Order 1002 confirmed
[push] Grace: Order 1003 confirmed
Unhandled exception. System.Runtime.CompilerServices.SwitchExpressionException: Non-exhaustive switch expression failed to match its input.
Unmatched value was Push.

All three notifications went out, then the program blew up in the billing method. The compiler had actually told us; the build output contained this warning:

Program.cs(47,75): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'NotificationChannel.Push' is not covered.

But it was a warning, not an error, and it got lost among hundreds of others. The real problem is deeper: the concept of "a new channel" doesn't live in one place, it lives inside every switch that branches on the channel type. This file has two; a real project may have dozens, in template selection, character limits, delivery-hour restrictions, reporting and so on. Every new channel requires finding and changing all of them, and forgetting one produces this output.

The Open/Closed Principle (OCP) targets exactly this situation: adding a new channel should not require opening and changing existing, working code. In this post we cover the two definitions of OCP, the ways to build extension points in .NET (interfaces, keyed services, Func<>, plugins) and OCP's most expensive side effect: putting extension points where nothing will change.

Two Definitions: Meyer and Martin

The first form of OCP comes from the 1988 book Object-Oriented Software Construction by Bertrand Meyer (designer of the Eiffel language and originator of Design by Contract). According to Meyer, a module should be both open and closed. Open: it is still available for extension, new fields or new operations can be added. Closed: it is available for use by other modules, its interface is stable and well defined, it has been compiled and put in a library. Meyer resolves the contradiction with implementation inheritance: without touching the closed class, you write a new class that derives from it, and old clients keep using the old class.

Robert C. Martin (also known as "Uncle Bob", author of Clean Code and Clean Architecture) reinterpreted the principle in an article published in the C++ Report in 1996. In his version, what is closed is an abstraction (an abstract class or an interface); what is open is the set of new implementations of that abstraction. The client depends on the abstraction, and new behavior arrives as a new implementation. This version is called the "polymorphic OCP", and it is usually what people mean by OCP today. In his 2014 post Martin puts the principle even more simply: you should be able to extend the behavior of a system without modifying that system. In the same post he points to plugin architectures (Visual Studio, Eclipse, IntelliJ) as the ultimate and most successful application of OCP.

The difference between the two definitions matters in practice. Meyer's inheritance-based approach produces subclasses that depend on the internal details of the base class; we will see how that leads to the fragile base class problem in the upcoming composition over inheritance post. Martin's approach brings the client and the implementation together only through an interface; this is also why LSP (Liskov Substitution Principle) matters so much: if a new implementation doesn't keep the abstraction's promise, "extending without modifying" becomes an illusion.

Closing the Switch: INotificationChannel and Keyed Services

We gather everything that varies by channel (sending, cost) behind a single abstraction. Notifications.cs:

using Microsoft.Extensions.DependencyInjection;

namespace Bookstore.Notifications;

public sealed record Customer(int Id, string Name, string PreferredChannel);

// Extension point: new channel = a new class implementing this interface
public interface INotificationChannel
{
    decimal CostPerMessage { get; }
    void Send(Customer customer, string message);
}

public sealed class EmailChannel : INotificationChannel
{
    public decimal CostPerMessage => 0.001m;
    public void Send(Customer customer, string message) => Console.WriteLine($"[email] {customer.Name}: {message}");
}

public sealed class SmsChannel : INotificationChannel
{
    public decimal CostPerMessage => 0.05m;
    public void Send(Customer customer, string message) => Console.WriteLine($"[sms] {customer.Name}: {message}");
}

public sealed class PushChannel : INotificationChannel
{
    public decimal CostPerMessage => 0.0001m;
    public void Send(Customer customer, string message) => Console.WriteLine($"[push] {customer.Name}: {message}");
}

// This class does not change when a channel is added
public sealed class NotificationService(Func<string, INotificationChannel> channelFor)
{
    public decimal TotalCost { get; private set; }

    public void OrderConfirmed(Customer customer, int orderId)
    {
        INotificationChannel channel = channelFor(customer.PreferredChannel);
        channel.Send(customer, $"Order {orderId} confirmed");
        TotalCost += channel.CostPerMessage;
    }
}

// A client with a fixed channel: names the key in the constructor
public sealed class PasswordResetService([FromKeyedServices("sms")] INotificationChannel sms)
{
    public void SendCode(Customer customer) => sms.Send(customer, "Your reset code is 482913");
}

We decide which key finds which channel at the application's entry point, in the composition root. Keyed services, which arrived with .NET 8, are useful here: you can register several implementations of the same interface under a key and resolve them by key. Program.cs:

using System.Globalization;
using Bookstore.Notifications;
using Microsoft.Extensions.DependencyInjection;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

ServiceCollection services = new();

// New channel = new class + one line here
services.AddKeyedSingleton<INotificationChannel, EmailChannel>("email");
services.AddKeyedSingleton<INotificationChannel, SmsChannel>("sms");
services.AddKeyedSingleton<INotificationChannel, PushChannel>("push");

// Selection by key happens only in the composition root
services.AddSingleton<Func<string, INotificationChannel>>(
    sp => key => sp.GetRequiredKeyedService<INotificationChannel>(key));
services.AddSingleton<NotificationService>();
services.AddSingleton<PasswordResetService>();

using ServiceProvider provider = services.BuildServiceProvider();

NotificationService notifications = provider.GetRequiredService<NotificationService>();
Customer[] customers = [new(1, "Ada", "email"), new(2, "Alan", "sms"), new(3, "Grace", "push")];
foreach (Customer customer in customers)
    notifications.OrderConfirmed(customer, orderId: 1000 + customer.Id);

Console.WriteLine($"Cost: {notifications.TotalCost} EUR");

provider.GetRequiredService<PasswordResetService>().SendCode(customers[0]);

try
{
    notifications.OrderConfirmed(new Customer(4, "Linus", "fax"), orderId: 1004);
}
catch (InvalidOperationException ex)
{
    Console.WriteLine(ex.Message);
}
[email] Ada: Order 1001 confirmed
[sms] Alan: Order 1002 confirmed
[push] Grace: Order 1003 confirmed
Cost: 0.0511 EUR
[sms] Ada: Your reset code is 482913
No keyed service for type 'Bookstore.Notifications.INotificationChannel' using key type 'System.String' has been registered.

NotificationService no longer knows the channel types; it only says "give me a channel for this key". When a WhatsApp channel arrives, the work is: a new class implementing INotificationChannel and a single AddKeyedSingleton line in Program.cs. NotificationService, PasswordResetService and the existing channels stay closed. A forgotten channel doesn't silently compute a wrong cost either; it throws a clear exception on the first attempt.

There are three design decisions here, and the reasoning behind each matters. First, cost is now the channel's own property. The second switch in the original code was "channel-specific knowledge" living outside the channel; moving it inside leaves no second place to forget. Second, resolution by key happens only in the composition root, inside a Func<string, INotificationChannel> registration. Giving NotificationService an IServiceProvider and calling GetRequiredKeyedService inside would also work, but that would be a service locator: the class's real dependencies would disappear from its constructor. We will discuss in detail why that is bad in the upcoming Dependency Inversion post. Third, the password reset code always goes by SMS (Short Message Service); clients with a fixed channel such as PasswordResetService state the key explicitly in the constructor with the [FromKeyedServices("sms")] attribute.

One detail you should know about keyed services: keyed registrations do not show up in a normal IEnumerable<INotificationChannel> resolution. In a small experiment where I registered two channels, GetServices<INotificationChannel>() returned zero results, while GetKeyedServices<INotificationChannel>(KeyedService.AnyKey) returned both registered channels. If you have a scenario that needs to walk over all channels (say, "send a maintenance notice on every channel"), design for this deliberately. For the ASP.NET Core side, see "What's New in ASP.NET Core with .NET 8" in the ASP.NET Core Web API (Application Programming Interface) series; for the general mechanics of the container, see "Dependency Injection Basics" in the Advanced C# series.

OCP in .NET: HttpClient's Handler Pipeline

Perhaps the most widely used example of OCP in .NET's own library is HttpClient's message handler chain. HttpClient doesn't send the request itself; it hands it to an HttpMessageHandler. A DelegatingHandler is an intermediate link that passes the request on to the next handler. Thanks to this design you can add behaviors such as logging, retries, authentication headers or caching without touching HttpClient's code:

using System.Net;
using System.Text;

// We add behavior in between without touching HttpClient's code
HttpMessageHandler pipeline = new LoggingHandler { InnerHandler = new FakeCatalogHandler() };
using HttpClient client = new(pipeline) { BaseAddress = new Uri("https://catalog.example.com/") };

string json = await client.GetStringAsync("books/978-0441013593");
Console.WriteLine(json);

public sealed class LoggingHandler : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        Console.WriteLine($"--> {request.Method} {request.RequestUri}");
        HttpResponseMessage response = await base.SendAsync(request, cancellationToken);
        Console.WriteLine($"<-- {(int)response.StatusCode} {response.Content.Headers.ContentType}");
        return response;
    }
}

// A fake last link that never goes to the network
public sealed class FakeCatalogHandler : HttpMessageHandler
{
    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken) =>
        Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StringContent("""{"isbn":"978-0441013593","title":"Dune"}""", Encoding.UTF8, "application/json")
        });
}
--> GET https://catalog.example.com/books/978-0441013593
<-- 200 application/json; charset=utf-8
{"isbn":"978-0441013593","title":"Dune"}

HttpClient and DelegatingHandler live inside .NET and are closed; your LoggingHandler is a new implementation, the open end. In ASP.NET Core, IHttpClientFactory with AddHttpMessageHandler builds this chain through DI (dependency injection); Polly-based resilience handlers plug into the same spot. You see the same idea in JsonConverter<T> in System.Text.Json (teaching the serializer a new type), in the ASP.NET Core middleware pipeline and in the IComparer<T> passed to List<T>.Sort. What they share: the framework doesn't expect you to change its source code for your needs; it leaves a place where it can call your code.

Lightweight Extension Points: Func and Plugins

Not every extension point needs an interface. If what varies is a single function with no state, a delegate is enough:

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

// The rule is a single function: a lambda is enough instead of an interface + class
Func<decimal, decimal> standard = subtotal => subtotal >= 50m ? 0m : 4.99m;
Func<decimal, decimal> blackFriday = _ => 0m;

ShippingQuote regular = new(standard);
ShippingQuote campaign = new(blackFriday);

Console.WriteLine($"{regular.For(32.48m)} / {regular.For(64.00m)} / {campaign.For(32.48m)}");

public sealed class ShippingQuote(Func<decimal, decimal> rule)
{
    public decimal For(decimal subtotal)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(subtotal);
        return rule(subtotal);
    }
}
4.99 / 0 / 0

ShippingQuote knows nothing about campaign rules; a new campaign means a new lambda. LINQ (Language Integrated Query) itself works this way: to change the behavior of Where, you don't change its source, you change the predicate you pass to it. The limit of Func<> shows when several related operations (such as sending and cost) change together; at that point an interface is more readable and consistent than carrying two independent delegates.

At the other end of the spectrum are plugins: loading assemblies at run time that were unknown when the main application was compiled. In .NET the tool for this is AssemblyLoadContext; Microsoft Learn has a guide titled "Create a .NET Core application with plugins". A plugin architecture takes OCP to the extreme, but its cost is also the highest: versioning, dependency conflicts, security and debugging. For a Bookstore application a class per channel is usually enough; plugins are for cases where third-party teams extend your application.

No Code Is Closed Against Every Change

Martin makes an important caveat in the 1996 article: no matter how closed a module is, there is always some kind of change against which it is not closed; that is why closure must be strategic. Our design is closed against adding new channels. But what if marketing says "notifications should be able to carry an image"? The signature of INotificationChannel.Send changes and every channel is opened. What if they say "don't send notifications at night in the customer's time zone"? That rule belongs to scheduling, not to the channel, and our design has no place for it.

This is not a design flaw; it is the nature of OCP. An abstraction closes one particular axis of change. Deciding which axis to close requires knowing which change will actually arrive. The switch in the original code has an interesting flip side too: if the channels are fixed but the operations performed on them keep growing (reports, templates, quotas, archiving), adding a new method to the interface for every new operation opens every channel, whereas an exhaustive switch on the same enum adds the new operation in one place and the compiler points at the forgotten branch with CS8509 (raising that warning to error level in the project is a good idea in this case). This is the tension known in the programming language literature as the expression problem: you have to choose whether to make adding new types cheap or adding new operations cheap.

The Speculative Generality Trap

The most expensive misuse of OCP is putting extension points where no change is expected. In code reviews you see structures like this: an ITaxCalculator with a single implementation, an ITaxCalculatorFactory producing it, a strategy choosing the factory; the justification is "in case other countries come along". Martin Fowler and Kent Beck (the creator of Extreme Programming) define this as a code smell called speculative generality in Fowler's Refactoring (1999). Fowler's YAGNI (You Aren't Gonna Need It) post breaks the cost of such up-front flexibility into items: the cost of building it, the delay of the work not done in the meantime, the cost of carrying it in the code base and the cost of repairing it when the guess turns out wrong. We will cover these in detail in the DRY (Don't Repeat Yourself), KISS (Keep It Simple, Stupid) and YAGNI post.

The guess is often wrong. Bookstore sells to a single country for years, and the first real change on the tax side is not a country but a rounding rule; and that rule is not on the axis the factory closed. What remains is three files of indirection and the question "why are there so many layers here?" for every reader.

Martin's suggestion in his book Agile Software Development is a practical rule: "fool me once". Write the code first as if it won't change, in its simplest form. When the first change of a particular kind arrives, build the abstraction that protects you against changes of that kind at that point. For the code at the top of this post, the right moment was not when the second channel (SMS) arrived, but perhaps when the third one arrived and the switch statements started multiplying. The commonly heard rule "abstract when it changes a second time" is a version of the same idea.

Common Pitfalls

Reading OCP as "never touch old code". Bug fixes, refactoring and correcting a wrong abstraction always change existing code, and they should. OCP aims at not opening working code to add a new behavior; it does not aim at freezing code.

Designing the abstraction around a single implementation. An INotificationChannel written while email was the first and only channel usually carries an email-specific signature like Send(string to, string subject, string htmlBody); when SMS arrives, "subject" makes no sense. An interface written before the second real example arrives copies the shape of the first.

Making the extension point undiscoverable. If finding which interface to implement, where to register it and which key to use takes an hour when adding a new channel, OCP is satisfied on paper but not in practice. Keeping registrations in one place (the composition root) and defining keys as constants makes this easier.

Scattering keys as strings. If the string "sms" appears in the registration, in [FromKeyedServices] and in the database, that is exactly the connascence of meaning we saw in the previous post. Defining the keys as const values inside a static class NotificationChannels moves typos to compile time.

A Small Experiment

★☆☆ In the project from the first example, turn CS8509 into an error (<WarningsAsErrors>CS8509</WarningsAsErrors>) and run the build again. Which line broke? In which scenario is this setting a good alternative to OCP?

★★☆ Add a WhatsAppChannel to the keyed services example. How many files did you have to touch? Then make the "fax" customer fall back to email instead of throwing an exception; where did you put that rule, and why?

★★★ Add the rule "don't send push between 22:00 and 08:00" without changing any existing channel. Hint: a class that wraps INotificationChannel. Which design pattern does this solution resemble, and which axis of OCP does it use?

In the next post we test the assumption OCP rests on: does a new implementation really keep the abstraction's promise? We will discuss the Liskov Substitution Principle, the four rules of behavioral subtyping and the NotSupportedExceptions in .NET's own collections.