Sealed classes and members in C#: what they prevent, why sealing helps the JIT and type checks, CA1852, testing trade-offs, and why unsealing is safe but sealing is breaking.
A sealed class cannot be inherited, and a sealed override cannot be overridden further. Sealing says "this type was not designed for extension", which protects its invariants. It also helps performance: the JIT can devirtualise and inline calls on sealed types, and type checks and casts to a sealed class are a single method-table comparison. Structs and static classes are already sealed. Analyser CA1852 suggests sealing internal types with no subtypes. The trade-off is that classic mocking libraries cannot mock sealed classes, which is a hint to depend on an interface at that seam. Unsealing later is non-breaking; sealing a public class later is breaking.
sealed on a class means no class can derive from it; sealed override on a member means no further overrides below that point. My view: yes, application classes should be sealed by default and unsealed only when you deliberately design for inheritance. I'd justify it with design, performance, testing and versioning arguments.
public sealed class OrderService(IOrderRepository repo) { ... } // can't be inherited
public class Report
{
public virtual string Render() => "...";
}
public class SalesReport : Report
{
public sealed override string Render() => "..."; // subclasses of SalesReport can't override Render
}
Static classes are implicitly sealed (and abstract). Records can be sealed (sealed record), which also simplifies their generated equality. Structs are always sealed.
Design and correctness
Performance
ToString, Equals, interface calls) on a variable of a sealed type become direct calls that can be inlined.obj is SealedType is a single MethodTable comparison, no hierarchy walk.HttpClient), mock at a different seam (HttpMessageHandler, IHttpClientFactory).| Change | Breaking? |
|---|---|
| Unsealing a sealed class | No |
| Sealing an unsealed public class | Yes — breaks anyone who derived from it |
Adding sealed to an override |
Yes for subclasses that override it further |
Removing sealed from an override |
No |
For public library types, sealing is a stronger commitment: you can unseal later without breaking anyone, but you can't seal once published without a major version. For application code where you own all callers, sealing is pure benefit — you can unseal any time you actually need inheritance.
ControllerBase, BackgroundService, DelegatingHandler, your own abstract templates).We enabled CA1852 as a warning across a large solution, sealed ~600 internal classes in one pull request (mostly mechanical with dotnet format), and saw a small but measurable throughput improvement in a CPU-heavy pricing service (a few percent, from devirtualised Equals/GetHashCode calls in dictionary-heavy code). The bigger win was design clarity: a couple of builds failed because tests were subclassing services to override methods — which exposed missing interfaces at real boundaries that we then fixed.
Checks whether the candidate sees sealed as a design and API decision, with a performance bonus.
Green flags: class versus member sealing; devirtualisation and cheaper type checks; CA1852; testing via interfaces instead of mocking classes; knows sealing is breaking and unsealing is not; mentions EF proxies or other cases where sealing hurts.
Red flags: "sealed is for security" without explanation; never seals anything; unseals to mock; thinks sealing makes types immutable.
call (or inlines it).is/as/casts: for a sealed target type, obj is Foo compiles to obj != null && obj->MethodTable == Foo_MT — no call to a casting helper.GetType() == typeof(Foo) patterns similarly simplify.bool IsOrder(object o) => o is Order; // Order sealed -> single pointer compare
With dynamic PGO, unsealed types can get guarded devirtualisation, but sealed types get it unconditionally and without the guard.
Storing into an array of reference type requires a runtime type check (array covariance). If the array's element type is sealed, the JIT knows the exact element type and can often skip the expensive path.
For records, sealed removes the need for virtual EqualityContract checks to be overridable, simplifies generated Equals/PrintMembers, and the compiler can make ToString override sealed (C# 10 allows sealed override ToString in records so derived records don't regenerate it).
internal types without derived types in the same assembly (safe because no external code can derive).dotnet format analyzers --diagnostics CA1852 can apply fixes in bulk..editorconfig code-style rules and class templates so new classes are created sealed.| Need | Approach |
|---|---|
| Replace an external dependency | Interface at the boundary + fake |
| Control time | TimeProvider / FakeTimeProvider |
| HTTP calls | Custom HttpMessageHandler or a library like WireMock.Net |
| Database | Testcontainers with real SQL Server, or EF Core in-memory SQLite for some cases |
| Verify interactions inside a class | Usually a smell; test outcomes instead |
Lazy-loading proxies (UseLazyLoadingProxies) and change-tracking proxies generate subclasses at run time; they require public, non-sealed entity types and virtual navigation properties. If you don't use proxies (generally recommended — explicit loading and projections), entities can be sealed. EF Core's default snapshot change tracking works fine with sealed classes.
o is Order with Order sealed and unsealed, and a virtual method call on each.{WebUtility.HtmlEncode(body)}
"; } public sealed class MarketingEmail : EmailNotification { // public override string Format(string body) => body; // CS0239: Format is sealed } // Testing a sealed class: fake the boundary, not the class public interface ITaxRates { decimal For(string region); } sealed class FakeRates : ITaxRates { public decimal For(string region) => 0.10m; } [Fact] public void Total_adds_tax() { var calc = new OrderTotalCalculator(new FakeRates()); Assert.Equal(110m, calc.Total(OrderWithLine(100m)).Amount); } ```What happens: every class is open for inheritance by default. Why it is wrong: unintended subclassing, fragile designs, missed optimisations. Do instead: seal by default; unseal deliberately.
What happens: classes are unsealed and methods made virtual so tests can mock them. Why it is wrong: design bent for test tooling; tests coupled to implementation details. Do instead: mock interfaces at boundaries and use real objects internally.
What happens: a library seals a class in a minor version. Why it is wrong: breaks consumers who derived from it. Do instead: decide sealing before the first release; seal only in major versions.
What happens: EF Core lazy-loading proxies enabled with sealed entities. Why it is wrong: proxies can't be generated; runtime errors. Do instead: avoid lazy-loading proxies, or keep those entities unsealed with virtual navigations.
What happens: sealing presented as a major optimisation for I/O-bound services. Why it is wrong: gains are small per call; they matter in hot, CPU-bound paths. Do instead: seal for design reasons; treat performance as a bonus and measure hot paths.
What happens: base classes meant for extension are sealed. Why it is wrong: blocks legitimate use. Do instead: keep deliberate extension points open and document them.
sealed overrideWhat happens: an override that shouldn't be changed further remains virtual.
Why it is wrong: deeper subclasses can alter behaviour unexpectedly.
Do instead: use sealed override where the chain should end.
What happens: frustration that string or HttpClient can't be subclassed.
Why it is wrong: those types are deliberately closed.
Do instead: use composition, extension methods/members, or the provided extension seams.
Seniors seal by default and open deliberately.
| Type | Default |
|---|---|
| DTOs, records, handlers, services | sealed |
| Domain entities (no lazy-loading proxies) | sealed |
| Framework extension points | Unsealed, with documented virtual members |
| Public library types | Decide before 1.0; unsealing later is safe, sealing is not |
| Middle layer of a template-method hierarchy | Unsealed, with sealed override on fixed steps |
They link sealing to the open/closed principle: a class should be open for extension through its designed seams (interfaces, strategies, events), not through arbitrary subclassing.
A virtual member can be overridden by a derived class, and a call to it is resolved by the run-time type of the object, not the variable's type. In C#, members are non-virtual by default (unlike Java), because every virtual member is a promise to subclasses and is harder to change. At run time the call loads the object's method table, reads the method's slot and calls through it, which prevents inlining unless the JIT can devirtualise (exact type known, sealed class or method, or dynamic PGO's guarded devirtualisation). Methods, properties, indexers and events can be virtual; static (outside interfaces), private and constructors cannot.
Inheritance lets a derived class reuse and specialise a base class: class SavingsAccount : Account. C# allows one base class but many interfaces. The derived class inherits all members except constructors and finalisers (private ones exist but are not accessible), can call base members with base., and can override virtual/abstract members. Declaring a member with the same name without override hides it (new), and which one runs then depends on the variable's static type, a common source of bugs. Use inheritance only for a real is-a relationship where a derived object can be used anywhere the base is expected (Liskov substitution). Deep hierarchies, inheriting just to reuse code, and fragile base classes are the main problems; composition is often better.
How to tell it (STAR)
**Situation:** in a large solution, methods were virtual only so tests could mock them, and one subclass skipped validation. **Task:** make the design intent explicit without breaking tests. **Action:** I enabled CA1852, sealed every class not in a real hierarchy, removed test-only virtuals and replaced class mocks with fakes behind small interfaces at I/O boundaries. **Result:** the validation bypass could no longer happen, serialisation benchmarks improved by about [5%], and [hundreds of] lines of brittle mock setup were removed.
Last updated 30 Oct 2026.