I used inheritance for years without questioning one uncomfortable detail.
I could create a base class, define the derived types I knew about, and write logic for every one of them.
But the compiler could never promise that my list was complete.
Somebody could create another derived class tomorrow.
Maybe in another project.
Maybe in another NuGet package.
And the switch expression that looked complete today would quietly become incomplete.
Nothing would stop it.
C# 15 introduces closed hierarchies, a preview feature supported by .NET 11 preview tooling. It does not “fix inheritance” as a whole, and it does not introduce multiple inheritance. What it solves is a specific problem C# developers have lived with for years:
How can a base class remain polymorphic while allowing the compiler to know every permitted direct subtype?
The answer is the new closed modifier.
Join me to master .NET Full Stack Development & boost your skills by 1% daily with insights, and techniques https://dotnetfullstackdev.gumroad.com/
The problem never looked serious in small examples
Consider a payment result.
A payment can succeed, fail, or require additional verification.
An experienced C# developer might model that with records:
public abstract record PaymentResult;
public sealed record PaymentSucceeded(
string TransactionId) : PaymentResult;
public sealed record PaymentFailed(
string Reason) : PaymentResult;
public sealed record VerificationRequired(
string VerificationUrl) : PaymentResult;This already looks clean.
The types express the domain better than returning a boolean.
We can now process the result using pattern matching:
public static string GetMessage(PaymentResult result)
{
return result switch
{
PaymentSucceeded success =>
$"Payment completed: {success.TransactionId}",
PaymentFailed failure =>
$"Payment failed: {failure.Reason}",
VerificationRequired verification =>
$"Continue verification at {verification.VerificationUrl}",
_ =>
"Unknown payment result"
};
}The last arm looks harmless:
_ => "Unknown payment result"I used to think of it as defensive programming.
Later, I realized it was also hiding a weakness in the type model.
The compiler did not know whether we had covered everything
As developers, we knew that PaymentResult was intended to have only three cases.
The compiler did not.
PaymentResult was an open abstract class.
Any consumer could add another derived type:
public sealed record PaymentCancelled(
string CancelledBy) : PaymentResult;The original switch would still compile.
At runtime, PaymentCancelled would fall into the default arm:
_ => "Unknown payment result"The application would continue running.
That sounds safe.
But imagine that instead of producing a display message, the switch handled money, refunds, inventory, or account status.
A default arm could silently turn a missing business decision into generic behavior.
The code would compile.
Tests might pass.
The design mistake could remain invisible until production encountered the new type.
I used to solve this with discipline
We had conventions.
“Keep all subclasses in this project.”
“Whenever you add a new result type, search for every switch.”
“Do not derive from this class outside the domain layer.”
Those rules were reasonable.
They were also entirely human.
The compiler did not enforce them.
That is the part C# 15 changes.
The new idea: a hierarchy can now be intentionally closed
With C# 15, the base type can be declared using closed:
public closed record class PaymentResult;
public sealed record class PaymentSucceeded(
string TransactionId) : PaymentResult;
public sealed record class PaymentFailed(
string Reason) : PaymentResult;
public sealed record class VerificationRequired(
string VerificationUrl) : PaymentResult;A closed class restricts its direct derived types to the same declaring assembly and module.
The compiler can therefore discover the complete set of direct descendants.
Because the set is known, it can verify whether pattern matching handles every case.
There is another small detail that matters:
A closed class is implicitly abstract.
You cannot instantiate the base type directly.
This is valid:
PaymentResult result =
new PaymentSucceeded("TXN-1042");This is not:
var result = new PaymentResult();That fits the intention.
PaymentResult represents the family.
The derived records represent the actual cases.
Now the switch can be genuinely complete
With a closed hierarchy, we can remove the catch-all arm:
public static string GetMessage(PaymentResult result)
{
return result switch
{
PaymentSucceeded success =>
$"Payment completed: {success.TransactionId}",
PaymentFailed failure =>
$"Payment failed: {failure.Reason}",
VerificationRequired verification =>
$"Continue verification at {verification.VerificationUrl}"
};
}Earlier, this kind of switch over an abstract base type generally needed a default arm because another subtype might exist.
Now the compiler knows that these are all the direct cases.
The absence of _ is not carelessness.
It is evidence that the hierarchy has been exhaustively handled.
That is the real improvement.
Not shorter syntax.
Not inheritance convenience.
Compiler-enforced completeness.
The moment this becomes valuable
Suppose the business adds a fourth result:
public sealed record class PaymentPendingReview(
string ReviewReference) : PaymentResult;In the old design, the application might still compile.
The new case could quietly fall into _.
In the closed design, the compiler can report that existing switches no longer cover every possible PaymentResult.
We are forced to make a decision:
public static string GetMessage(PaymentResult result)
{
return result switch
{
PaymentSucceeded success =>
$"Payment completed: {success.TransactionId}",
PaymentFailed failure =>
$"Payment failed: {failure.Reason}",
VerificationRequired verification =>
$"Continue verification at {verification.VerificationUrl}",
PaymentPendingReview pending =>
$"Payment is under review: {pending.ReviewReference}"
};
}This is the feature’s central benefit.
When the domain changes, the compiler helps locate the code that must be reconsidered. Microsoft’s tutorial describes this directly: adding a new case causes incomplete matches to be identified, pointing developers toward the places that require changes.
That is far more useful than allowing an unknown case to drift into generic fallback logic.
Why ordinary sealed was not enough
When I first heard about closed, my immediate question was:
“Don’t we already have
sealed?”
We do.
But sealed solves the opposite problem.
A sealed class says:
“Nobody may inherit from this class.”
public sealed class FinalInvoice
{
}A closed base class says:
“This class may have derived types, but its direct family must be defined here.”
public closed record class PaymentResult;That distinction is important.
With sealed, the hierarchy ends.
With closed, the hierarchy is controlled.
We still get polymorphism.
We still get separate derived types.
But the family boundary becomes visible to the compiler.
It is not simply abstract + sealed
C# historically did not allow a useful “abstract and sealed” class hierarchy because those concepts conflict:
abstractexpects derivation.sealedforbids derivation.
closed introduces a different rule.
Derivation is allowed, but only within the declaring assembly for direct descendants. The type is implicitly abstract and cannot also be declared sealed, static, or explicitly abstract.
That gives us something we previously had to express through documentation and conventions.
Why the restriction is assembly-based
A closed hierarchy does not necessarily mean every derived type must be in the same file.
It means the direct descendants must be controlled within the same assembly and module.
That makes the feature practical for real projects.
A domain assembly could contain:
Payments.Domain
PaymentResult.cs
PaymentSucceeded.cs
PaymentFailed.cs
VerificationRequired.csThe hierarchy can be spread across multiple files and namespaces while still remaining under the ownership of one compiled component.
An external application referencing Payments.Domain.dll cannot introduce another direct PaymentResult.
This is a useful architectural boundary.
The domain team owns the permitted cases.
Consumers may handle them, but they cannot silently extend the direct family.
There is a subtle detail: child types are not automatically closed
This initially surprised me.
Consider:
public closed class Notification;
public class EmailNotification : Notification;
public class SmsNotification : Notification;Notification is closed.
That means only the declaring assembly can add direct subclasses such as EmailNotification and SmsNotification.
But EmailNotification is not automatically closed.
Another assembly may still derive from it:
public class MarketingEmailNotification
: EmailNotification;That is intentional.
Closedness applies to the direct hierarchy unless you explicitly close or seal deeper levels.
So we must design the tree deliberately.
When every case should be final
For result types, commands, workflow states, and domain outcomes, we often want the individual cases to be final:
public closed record class ExportResult;
public sealed record class ExportCompleted(
string FilePath) : ExportResult;
public sealed record class ExportFailed(
string Error) : ExportResult;
public sealed record class ExportCancelled(
string Reason) : ExportResult;Here, the base family is closed and each case is sealed.
No external extension is possible.
The entire model is controlled.
When one branch should remain extensible
Sometimes we want a fixed set of top-level categories but allow one category to grow.
public closed class ApplicationEvent;
public sealed class UserRegistered : ApplicationEvent;
public class IntegrationEvent : ApplicationEvent;The direct family of ApplicationEvent is controlled:
UserRegisteredIntegrationEvent
But external libraries could derive specialized integration events:
public sealed class SalesforceContactCreated
: IntegrationEvent;This gives architects a useful middle ground.
The top-level classification remains exhaustive.
A selected branch remains open for extension.
The old approaches we used before closed hierarchies
C# developers have not been completely helpless.
We have modeled closed families in several ways.
Each worked.
Each had limits.
Enums worked until the cases needed data
An enum is naturally closed:
public enum PaymentStatus
{
Succeeded,
Failed,
VerificationRequired
}The compiler knows the members.
But enums carry no case-specific data.
A successful payment needs a transaction ID.
A failure needs a reason.
Verification needs a URL.
We usually ended up with one large object containing many nullable properties:
public sealed class PaymentResponse
{
public PaymentStatus Status { get; init; }
public string? TransactionId { get; init; }
public string? FailureReason { get; init; }
public string? VerificationUrl { get; init; }
}Now invalid combinations become possible:
var response = new PaymentResponse
{
Status = PaymentStatus.Succeeded,
FailureReason = "Insufficient balance"
};The type system permits a successful payment with a failure reason.
We can validate it manually.
But the model itself does not prevent it.
A class hierarchy lets every case carry only the data it needs.
Abstract classes modeled the data but remained open
Records and pattern matching improved this significantly:
public abstract record PaymentResult;
public sealed record PaymentSucceeded(
string TransactionId) : PaymentResult;
public sealed record PaymentFailed(
string Reason) : PaymentResult;The structure was good.
The missing piece was exhaustiveness.
The compiler could not be certain that other subclasses did not exist.
closed fills that gap.
Internal constructors helped, but only partially
Some teams tried controlling derivation using constructor accessibility:
public abstract class PaymentResult
{
internal PaymentResult()
{
}
}This prevented external assemblies from calling the constructor normally.
It was a useful pattern.
But the intent was indirect.
The compiler still did not use that design to prove exhaustive pattern matching.
The restriction existed as an implementation trick rather than a first-class language concept.
closed makes the intention explicit.
Visitor patterns gave completeness at the cost of ceremony
Before modern pattern matching, a visitor was sometimes used to force every case to be handled:
public interface IPaymentResultVisitor<T>
{
T Visit(PaymentSucceeded result);
T Visit(PaymentFailed result);
T Visit(VerificationRequired result);
}Each result would then dispatch itself to the visitor.
This could be strongly typed.
It also introduced several interfaces, methods, and layers for what should have been a simple domain decision.
Visitor still has legitimate uses.
But using it only to simulate exhaustive case handling often feels heavier than a closed hierarchy and a switch.
This is not a replacement for composition
Inheritance has another long-standing problem: developers use it where composition would be clearer.
Closed hierarchies do not change that.
This remains suspicious:
public class EmailService : LoggingService
{
}An email service is not naturally a kind of logging service.
It probably uses a logger:
public sealed class EmailService
{
private readonly ILogger<EmailService> _logger;
public EmailService(ILogger<EmailService> logger)
{
_logger = logger;
}
}closed does not make deep inheritance trees healthier.
It does not solve fragile base classes.
It does not remove tight coupling.
It solves one focused problem: controlling a family of subtypes so the compiler can reason about all direct cases.
That is why the title “C# solved inheritance” needs care.
C# 15 did not solve every inheritance problem.
It solved an important missing expression in the language.
Where closed hierarchies fit beautifully
The feature is most valuable when the domain has a meaningful, finite set of outcomes.
Operation results
public closed record class CreateOrderResult;
public sealed record class OrderCreated(
Guid OrderId) : CreateOrderResult;
public sealed record class DuplicateOrder(
string ExternalReference) : CreateOrderResult;
public sealed record class CustomerBlocked(
string Reason) : CreateOrderResult;The caller must consider every known outcome.
That is better than returning null, booleans, or generic exceptions for expected business states.
Workflow states
public closed record class ReportState;
public sealed record class Pending : ReportState;
public sealed record class Generating(
int ProgressPercentage) : ReportState;
public sealed record class Completed(
Uri DownloadUrl) : ReportState;
public sealed record class Failed(
string ErrorCode,
string Message) : ReportState;Now state-specific data lives with the correct state.
A completed report has a download URL.
A failed report has error information.
A pending report does not pretend to have either.
Commands in a controlled domain
public closed record class AccountCommand;
public sealed record class Deposit(
decimal Amount) : AccountCommand;
public sealed record class Withdraw(
decimal Amount) : AccountCommand;
public sealed record class Freeze(
string Reason) : AccountCommand;A handler can process the command exhaustively:
public static Account Apply(
Account account,
AccountCommand command)
{
return command switch
{
Deposit deposit =>
account with
{
Balance = account.Balance + deposit.Amount
},
Withdraw withdrawal =>
account with
{
Balance = account.Balance - withdrawal.Amount
},
Freeze freeze =>
account with
{
IsFrozen = true,
FreezeReason = freeze.Reason
}
};
}When a new command is added, this code cannot quietly ignore it.
That is exactly the kind of pressure I want from a compiler.
A more realistic ASP.NET Core example
Imagine an order service.
The application layer returns a controlled result family:
public closed record class PlaceOrderResult;
public sealed record class OrderPlaced(
Guid OrderId,
decimal Total) : PlaceOrderResult;
public sealed record class ProductUnavailable(
Guid ProductId) : PlaceOrderResult;
public sealed record class PaymentDeclined(
string Reason) : PlaceOrderResult;
public sealed record class InvalidOrder(
IReadOnlyCollection<string> Errors) : PlaceOrderResult;The service contains business logic:
public sealed class PlaceOrderService
{
private readonly IProductRepository _products;
private readonly IPaymentGateway _payments;
private readonly IOrderRepository _orders;
public PlaceOrderService(
IProductRepository products,
IPaymentGateway payments,
IOrderRepository orders)
{
_products = products;
_payments = payments;
_orders = orders;
}
public async Task<PlaceOrderResult> PlaceAsync(
PlaceOrderRequest request,
CancellationToken cancellationToken)
{
if (request.Items.Count == 0)
{
return new InvalidOrder(
["The order must contain at least one item."]);
}
foreach (var item in request.Items)
{
var available = await _products.IsAvailableAsync(
item.ProductId,
item.Quantity,
cancellationToken);
if (!available)
{
return new ProductUnavailable(item.ProductId);
}
}
var total = await CalculateTotalAsync(
request,
cancellationToken);
var payment = await _payments.ChargeAsync(
request.CustomerId,
total,
cancellationToken);
if (!payment.Succeeded)
{
return new PaymentDeclined(payment.FailureReason);
}
var order = new Order(
Guid.NewGuid(),
request.CustomerId,
total);
await _orders.SaveAsync(order, cancellationToken);
return new OrderPlaced(order.Id, total);
}
private Task<decimal> CalculateTotalAsync(
PlaceOrderRequest request,
CancellationToken cancellationToken)
{
// Simplified for the article.
return Task.FromResult(
request.Items.Sum(item =>
item.UnitPrice * item.Quantity));
}
}The API endpoint translates domain outcomes into HTTP responses:
app.MapPost(
"/orders",
async (
PlaceOrderRequest request,
PlaceOrderService service,
CancellationToken cancellationToken) =>
{
var result = await service.PlaceAsync(
request,
cancellationToken);
return result switch
{
OrderPlaced placed =>
Results.Created(
$"/orders/{placed.OrderId}",
placed),
ProductUnavailable unavailable =>
Results.Conflict(new
{
message = "A product is unavailable.",
unavailable.ProductId
}),
PaymentDeclined declined =>
Results.UnprocessableEntity(new
{
message = "Payment was declined.",
declined.Reason
}),
InvalidOrder invalid =>
Results.BadRequest(new
{
errors = invalid.Errors
})
};
});What I like about this design is not the syntax.
It is the pressure it creates.
The domain service must return one recognized result.
The API must translate every result.
If a new business case is introduced, the incomplete switches become visible during development rather than hiding behind a generic fallback.
Why this matters more in large codebases
In a small application, you can remember where a base class is handled.
In a large codebase, you cannot.
The same hierarchy may appear in:
API response mapping
logging
persistence conversion
UI rendering
metrics
retry decisions
message publishing
tests
Suppose we add:
public sealed record class OrderRequiresApproval(
Guid ApprovalRequestId) : PlaceOrderResult;Without exhaustiveness support, every consumer depends on developers remembering to update it.
With a closed hierarchy, the compiler becomes part of the change-management process.
That is the architectural value.
The feature does not merely protect one switch.
It reduces the search space of future change.
Closed hierarchy versus union types
C# 15 also includes preview work on union types, and the two ideas are related.
Both address domains where a value can be one of a finite set of cases.
Closed hierarchies keep the familiar object-oriented model:
public closed record class PaymentResult;Union types provide another way to express a restricted collection of alternatives.
Microsoft’s current C# 15 documentation presents closed hierarchies and union types as related pieces of the language’s exhaustiveness story.
For an existing C# codebase already using classes, records, inheritance, and pattern matching, closed hierarchies may feel more natural.
They preserve ordinary subtype polymorphism while tightening the boundary.
What happens at runtime?
Most of the benefit is at compile time.
The closed modifier tells the compiler that direct derivation is restricted to the declaring assembly.
That knowledge allows pattern matching analysis to determine whether a switch is exhaustive.
At runtime, these are still ordinary class or record objects participating in normal reference-type inheritance and virtual dispatch.
This is not a special container around every value.
It is not an enum lookup.
It is not reflection-based case discovery in your application logic.
The important improvement is the compiler’s stronger understanding of the type family.
What closed hierarchies do not guarantee
They do not guarantee that every future version of a library will have the same cases.
The library owner can add another direct subtype in a later release.
When consumers rebuild against that newer version, incomplete pattern matches can be reported.
That is useful.
But API versioning still requires thought.
Adding a new case can affect consumers because they now need to decide how to handle it.
The language helps expose that change.
It does not remove the compatibility decision.
Accessibility also matters
For exhaustive matching, consumers need visibility into the relevant direct descendants.
Microsoft’s pattern documentation notes that when a direct descendant is not accessible from the switch location, the compiler cannot simply treat the visible cases as a complete match.
This means public library design needs care.
A public closed base with hidden internal descendants may not provide external consumers with the exhaustive experience they expect.
The hierarchy’s accessibility should match the intended consumption model.
Should every abstract class become closed?
No.
Some hierarchies are intentionally extension points.
ASP.NET middleware.
Framework providers.
Plugin models.
Serialization converters.
Custom validators.
UI controls.
A library may explicitly want consumers to derive their own types.
Closing those hierarchies would defeat their purpose.
Use closed when the domain says:
“These are the supported cases, and this component owns that list.”
Keep the hierarchy open when the architecture says:
“Consumers are expected to extend this model.”
That difference is more important than the keyword.
Trying it today
As of July 2026, C# 15 is a preview language release supported through .NET 11 preview SDKs and current Visual Studio 2026 Insiders builds. Preview features require an appropriate preview SDK and language configuration.
A project file may need preview language mode:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>Because the feature is still in preview, syntax and behavior should be checked against the current SDK and official documentation before production adoption.
That distinction matters.
This is an exciting feature.
It is not yet a reason to upgrade a critical production system blindly.
The deeper lesson is not really about inheritance
For years, C# allowed us to model families of objects.
What it could not fully express was this sentence:
“This family is complete, and I want the compiler to hold me accountable when it changes.”
We approximated it with enums.
We controlled constructors.
We used visitors.
We wrote default arms.
We relied on documentation.
C# 15 finally gives that intention a place in the type system.
And that is what changed my view of the feature.
It is not making inheritance more clever.
It is making inheritance more honest.
An open hierarchy says:
“Other types may appear. Be prepared.”
A closed hierarchy says:
“These are the cases. Handle them.”
That is a small difference in syntax.
But in a codebase that must survive years of business change, it can be a very large difference in confidence.


