Open/Closed Principle and extension members in .NET 10

Let's start with a scene you've lived through.

Someone opens a PR on the company's shared library. Common.Shipping, Acme.Core, whatever yours is called. The change is four lines. "Just adding a small helper method, it's additive, it can't break anything."

Two weeks later, twelve teams are pinned to the old version because the "additive" change touched an interface, and every implementor in every downstream repo stopped compiling.

That's the Open/Closed Principle failing in production. Not in a textbook.

What this article covers:

โœ… What OCP actually means when you're the one shipping the package

โœ… Why extension methods are the cheapest OCP tool .NET gives you

โœ… What C# 14 extension members in .NET 10 change โ€” and the one guarantee that matters most

โœ… Where the pattern stops working, so you don't over-apply it

๐ŸŽฏ OCP, Rewritten for Library Authors

The classic phrasing is "open for extension, closed for modification." Which sounds lovely and tells you almost nothing on a Tuesday afternoon.

Here's the version I actually use with my teams:

Closed = the surface you published stays put. Consumers upgrade without touching a single line of their own code.

Open = consumers can add behaviour without asking you, without a fork, and without waiting for your next release.

Notice what dropped out: nothing in there says "abstract base class." Nothing says "interface for everything." Those are implementation strategies that people confused with the principle itself, and that's how you end up with IThingProviderFactoryStrategy in a utility package.

Closed for modification doesn't mean frozen. It means nobody has to edit your code to get value out of it.

๐Ÿšช The Three Doors

Say you consume a package you don't own. Shipping.Core, version 3.2.0. It has a perfectly good ShippingRate type. You need one behaviour it doesn't have: VAT-inclusive amounts.

You have three options.

Fork, inherit, or extend a shipped library

Door 1 โ€” fork it. Clone, patch, publish Shipping.Core.Acme. Congratulations, you now maintain a shipping library. Every upstream release is a merge conflict, and every upstream security fix arrives late. This is literally modification. OCP said don't.

Door 2 โ€” inherit it. Works right up until the type is sealed, is a record, is an interface, or the method you want to hook is static. And even when it works, you've coupled yourself to the base type's internals โ€” a protected member changing shape in 3.3.0 breaks you.

Door 3 โ€” extend it. Zero lines changed in the package. Works on sealed types, interfaces, records, structs, and generics. Opt-in per file via using.

Door 3 is the lazy one. Lazy is why it wins.

๐Ÿงฉ Why Extension Methods Are OCP's Cheapest Tool

You already trust this pattern with your whole codebase, you just don't think of it as OCP.

LINQ. IEnumerable<T> has exactly one member: GetEnumerator(). Every single thing you do with it โ€” Where, Select, GroupBy, Aggregate โ€” was bolted on from outside via extension methods, without touching the interface. The interface has been closed since .NET 2.0. It's been open for extension ever since.

Dependency injection. Every builder.Services.AddSomething() you've ever typed is an extension method on IServiceCollection. Serilog, EF Core, MassTransit, your own internal packages โ€” none of them ever needed a line changed in the DI abstractions to plug in. That's the whole DI registration convention.

Now the part that matters for a shared library, and the thing I wish more people internalised:

// โŒ Adding a member to a published interface: BREAKING.
// Every implementor in every downstream repo stops compiling.
public interface IRateCalculator
{
    decimal Calculate(Parcel parcel);
    decimal CalculateWithVat(Parcel parcel, decimal vatRate); // <- the "small addition"
}

// โœ… Adding an extension member: NOT breaking.
// Zero implementors affected. Ships in a minor version.
public static class RateCalculatorExtensions
{
    public static decimal CalculateWithVat(
        this IRateCalculator calculator, Parcel parcel, decimal vatRate)
        => calculator.Calculate(parcel) * (1 + vatRate);
}

Same capability for the caller. Wildly different blast radius for everyone else.

This is the library-author move: keep the interface as small as you can defend, and grow the convenience surface from outside it. The Framework Design Guidelines recommend exactly that โ€” extension methods on interfaces are the sanctioned way to give an abstraction a rich API without inflating the contract.

And if you want the machine to enforce your "closed" side rather than your good intentions, turn on package validation. It compares your new package against the previously shipped one and fails the build on binary breaking changes. One MSBuild property:

<PropertyGroup>
  <EnablePackageValidation>true</EnablePackageValidation>
  <PackageValidationBaselineVersion>3.2.0</PackageValidationBaselineVersion>
</PropertyGroup>

OCP that a CI pipeline can check beats OCP that lives in a wiki page.

๐Ÿ†• What Changed in .NET 10

Extension methods have carried this weight since C# 3.0. They also had a ceiling: methods only.

You wanted rate.IsFree as a property? Nope, write rate.IsFree(). You wanted a factory-ish Money.Zero hanging off a type you don't own? Not possible. You wanted + on two sequences? Forget it.

C# 14 โ€” shipping with .NET 10 โ€” lifts that ceiling with extension members, declared inside an extension block.

Classic extension methods and C# 14 extension blocks compile to identical IL

The block declares the receiver once, and everything inside it gets to use it:

public static class ShippingRateExtensions
{
    // The receiver is declared once, for the whole block.
    extension(ShippingRate rate)
    {
        // Instance method โ€” the classic case.
        public decimal WithVat(decimal vatRate)
            => rate.Amount * (1 + vatRate);

        // Instance PROPERTY โ€” this is new.
        public bool IsFree => rate.Amount == 0m;

        // Operator โ€” also new.
        public static ShippingRate operator +(ShippingRate left, ShippingRate right)
            => new(left.Amount + right.Amount, left.Currency);
    }

    // Static members don't need a receiver name.
    extension(ShippingRate)
    {
        public static ShippingRate Free => new(0m, "EUR");
        public static ShippingRate FromCents(long cents) => new(cents / 100m, "EUR");
    }
}

And at the call site, none of it looks bolted on:

var rate = ShippingRate.FromCents(1250);   // static extension method
var total = rate + ShippingRate.Free;      // extension operator
if (total.IsFree) { /* ... */ }            // extension property
var withVat = total.WithVat(0.20m);        // extension method

ShippingRate doesn't know any of this exists. It was compiled, packaged, signed and shipped months ago. You just gave it a factory, a property and an operator from the outside.

Generics work the way you'd hope. The type parameter goes on the extension declaration when the receiver needs it, on the member when it doesn't:

public static class SequenceExtensions
{
    extension<T>(IEnumerable<T> source)
    {
        public IEnumerable<T> Spread(int start, int count)
            => source.Skip(start).Take(count);

        public static IEnumerable<T> Identity => [];
    }
}

๐Ÿ”’ The Guarantee That Actually Matters

Here's the line from the docs that changes how you plan a library migration:

Both forms of extension methods generate the same intermediate language (IL). Callers can't make a distinction between them. In fact, you can convert existing extension methods to the new member syntax without a breaking change. The formats are both binary and source compatible. โ€” Extension declaration, C# reference

Read that again if you maintain a package with a few hundred extension methods in it.

It means the modernisation is not a semver event:

Old this syntax New extension block
Emitted IL static method + attribute identical
Caller source change โ€” none required
Recompile needed downstream โ€” no
Semver impact โ€” patch/minor, not major
Properties, static members, operators โŒ โœ…

So you don't need a "big bang extension refactor" epic. You convert a file when you're already in it, in whatever PR you were already writing, and nobody downstream notices until they want to use the new property.

That's the rare kind of language feature: it upgrades your API's expressiveness without spending your consumers' upgrade budget.

โš ๏ธ Where It Stops Working

I'd be selling you something if I stopped here. Extension members are OCP's cheapest tool, not its universal one.

They're not virtual. Resolution is static, decided by the compiler from the declared type at the call site. If you need behaviour that varies by runtime type, you need polymorphism โ€” an interface, a strategy, a virtual member. An extension can't be overridden.

Instance members always win. If Shipping.Core 3.3.0 adds a real IsFree property to ShippingRate, your extension property silently stops being called. Same rule extension methods always had. It's not a compile error, and that's exactly what makes it sneaky โ€” pin your major versions and read release notes.

Consumers must recompile to see new members. Call sites bind at compile time. Bumping the package alone doesn't light up new extensions in an already-built assembly.

No new scope. An extension block groups members visually, but it doesn't create a scope. Every member in the static class still needs a unique signature, receiver included.

They can't see private state. Extensions get the public surface, nothing more. If your extension needs internals, the behaviour belongs in the type โ€” and if the type isn't yours, you have a design conversation to have with its owner, not a workaround to write.

Don't extend types you own. If you control the type and the behaviour belongs to it, put it in the type. Extension members are for crossing an ownership boundary. Using them inside your own assembly is usually just fear of touching your own code.

Namespace discipline. Extensions are only visible where their namespace is imported. Put them in the namespace of the type you're extending so they show up in IntelliSense naturally โ€” or in a deliberately separate one when they're opinionated and should be opt-in. Both are valid; pick on purpose, not by accident.

One last note for the roadmap: extension indexers land in C# 15, not C# 14. If you saw a demo with numbers[2] resolving to an extension, that's next, not now.

๐Ÿงช Try It

The full example โ€” a "published" Shipping.Core library that never gets edited, and a consumer that grows it with a classic extension method, an extension property, a static extension member and an operator โ€” runs as a small .NET 10 solution with assertions, so dotnet run fails loudly if any of it is wrong.

// Shipping.Core โ€” compiled, packaged, untouched.
public sealed record ShippingRate(decimal Amount, string Currency);

// Consumer โ€” adds four capabilities, changes zero lines of the library.
public static class ShippingRateExtensions
{
    extension(ShippingRate rate)
    {
        public bool IsFree => rate.Amount == 0m;
        public ShippingRate WithVat(decimal vatRate)
            => rate with { Amount = Math.Round(rate.Amount * (1 + vatRate), 2) };
    }

    extension(ShippingRate)
    {
        public static ShippingRate Free => new(0m, "EUR");
    }
}

Yes, it works on a record. That's the point โ€” record types are sealed-ish and hostile to inheritance, and extension members don't care.

๐ŸŽ Takeaways

  1. OCP for a library author = a stable published surface + an outside-in extension path. Not "add an interface to everything."
  2. Adding a member to a published interface is breaking. Adding an extension member isn't. Keep contracts small; grow convenience from outside.
  3. Extension methods were always the cheap door. LINQ and AddXxx() are the proof, running in every .NET app you own.
  4. C# 14 / .NET 10 removes the methods-only ceiling โ€” properties, static members and operators now live in extension blocks.
  5. Same IL, source and binary compatible. Migrating a published package's extension methods is not a breaking change, so migrate opportunistically instead of scheduling an epic.
  6. Static resolution is the trade-off. No virtual dispatch, instance members win, consumers recompile. Reach for polymorphism when behaviour must vary at runtime.
  7. Let CI hold the "closed" line with EnablePackageValidation, not code review vibes.

๐Ÿ“š Further Reading

Takeaway: A shared library is closed when nobody has to edit it to add value, and open when they don't have to ask you first. Extension members are the cheapest way to be both โ€” and in .NET 10 they finally cover properties, statics and operators without costing your consumers a recompile they didn't plan for.