Design judgement on interfaces versus abstract classes in C# 14: state, multiple implementation, versioning, default interface methods, static abstract members, and the interface-plus-base-class pattern.
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.
Default interface methods and static abstract members narrowed the gap, but the core difference remains: an interface is a contract a type can adopt (many per type, no state), and an abstract class is a partial implementation a type inherits (one per type, with state and constructors). My default is an interface; I add an abstract class when implementers genuinely share state or a fixed algorithm — often both together.
| Question | Points to interface | Points to abstract class |
|---|---|---|
| Do implementers need shared state (fields) or constructor logic? | No | Yes |
| Do I need to control the flow (template method) and keep hooks protected? | No | Yes |
| Must unrelated types (or structs) adopt the contract? | Yes | No (single inheritance, classes only) |
| Will types implement several such contracts? | Yes | No |
| Do consumers need to mock/substitute it easily? | Yes | Harder (only virtual members mockable) |
| Is it a capability ("can be compared", "can be disposed")? | Yes | No |
| Is it the core identity of a family with substantial shared behaviour? | Possibly | Yes |
| Do I need protected members for subclasses? | Limited (protected interface members only usable by derived interfaces) | Yes |
| Will I need to evolve it in a published library? | Possible via default implementations (no state) | Easy: add virtual members with defaults and state |
Parse, operators), enabling generic math (INumber<T>). Abstract classes can't express this — static members aren't polymorphic. This strengthens interfaces for type-level contracts.Interfaces
IDisposable, IAsyncDisposable, IComparable<T>, IEquatable<T> — capabilities on unrelated types (including structs).IEnumerable<T>, IReadOnlyList<T> — contracts implemented by many collections.ILogger, IOptions<T>, IHttpClientFactory — DI-friendly service contracts.INumber<T>, IParsable<T>, ISpanFormattable — static abstract/type-level contracts.Abstract classes
Stream — rich shared behaviour (CopyToAsync, ReadExactly, async wrappers) over a few abstract members.DbConnection, DbCommand (ADO.NET) — shared provider-agnostic behaviour and evolution over decades.HttpMessageHandler/DelegatingHandler — pipeline building blocks.JsonConverter<T>, TextWriter, TimeProvider (.NET 8: an abstract class so it could evolve with new virtual members).Both together
IHostedService + BackgroundService: the interface is the contract the host uses; the abstract class is a convenient implementation with the lifecycle handled.IComparer<T> + Comparer<T>, IEqualityComparer<T> + EqualityComparer<T>.ICollection<T> + Collection<T> (customisable via protected virtual InsertItem, RemoveItem).Publish the interface as the contract consumers depend on (and tests mock); provide an optional abstract base implementing the boring/common parts for implementers:
public interface IPaymentGateway
{
Task<PaymentResult> ChargeAsync(ChargeRequest request, CancellationToken ct);
}
public abstract class PaymentGatewayBase(ILogger logger) : IPaymentGateway
{
public async Task<PaymentResult> ChargeAsync(ChargeRequest request, CancellationToken ct)
{
// idempotency, validation, metrics, logging - fixed flow
return await SendChargeAsync(request, ct);
}
protected abstract Task<PaymentResult> SendChargeAsync(ChargeRequest request, CancellationToken ct);
}
Consumers never see the base class; implementers choose to use it or implement the interface directly.
We had an INotificationChannel interface implemented by email, SMS and Teams channels, each duplicating retry, templating and audit logic — with subtle differences (one didn't audit). We kept the interface for consumers and DI, and added an abstract NotificationChannelBase with a sealed template doing templating, audit and resilience, and an abstract DeliverAsync. Bugs from inconsistent cross-cutting behaviour stopped, and a new WhatsApp channel took an afternoon.
Checks modern, nuanced judgement rather than a C# 1-era answer.
Green flags: accurate capability table including C# 8 and C# 11 features; versioning reasoning; real BCL examples; interface-plus-base pattern; struct and static abstract considerations; Interface Segregation.
Red flags: "interfaces cannot have code"; "abstract classes are obsolete now"; huge interfaces; consumers depending on base classes.
| Feature | Interface | Abstract class |
|---|---|---|
| Instance fields | No | Yes |
| Constructors | No (static constructor only) | Yes |
| Access modifiers on members | Yes (C# 8+), default public | Yes, default private |
| Default implementations | Yes (C# 8+) | Yes (virtual or non-virtual) |
| Static abstract/virtual members | Yes (C# 11) | No |
| Implemented by structs | Yes (boxing if used via interface; generic constraints avoid it) | No |
| Multiple per type | Yes | One base class |
| Dispatch | Interface stub dispatch | Vtable |
| Versioning | Adding abstract members breaks; DIMs don't (runtime support required) | Adding virtual members doesn't break; abstract does |
The .NET team's historical guidance (Framework Design Guidelines, 2nd/3rd ed.) favoured abstract classes for evolvable public APIs because interfaces couldn't change. DIMs changed that partly, but:
netstandard2.0 can't use them.AmbiguousImplementationException when assemblies are updated independently.That's why newer BCL types like TimeProvider are abstract classes — the team expected to add virtual members later.
abstract/virtual members can be overridden by mocks; non-virtual template methods run real code. That can be good (testing the template) or awkward (unexpected dependencies in the base constructor).Prefer several small interfaces (IReadRepository<T>, IWriteRepository<T>) over one large one; a class can implement several. Abstract classes can't be combined that way, which is another reason interfaces are the default for contracts.
IParsable<T> on a value object and write a generic ParseAll<T>.// Contract for the host: small and easy to fake
public interface IImporter
{
string Format { get; }
Task<ImportResult> ImportAsync(Stream input, IProgress<int> progress, CancellationToken ct);
}
// Base for implementers: shared state and a fixed algorithm
public abstract class ImporterBase : IImporter
{
private readonly ILogger _logger;
protected ImporterBase(ILogger logger) => _logger = logger;
public abstract string Format { get; }
public async Task<ImportResult> ImportAsync(Stream input, IProgress<int> progress, CancellationToken ct)
{
using var reader = new StreamReader(input);
int rows = 0, failed = 0;
while (await reader.ReadLineAsync(ct) is { } line)
{
try { await ParseRowAsync(line, ct); rows++; }
catch (FormatException ex) { failed++; _logger.LogWarning(ex, "Bad row {Row}", rows + failed); }
if ((rows + failed) % 1000 == 0) progress.Report(rows + failed);
}
return new ImportResult(rows, failed);
}
protected abstract ValueTask ParseRowAsync(string line, CancellationToken ct);
}
public sealed class CsvImporter(ILogger<CsvImporter> logger, IOrderSink sink) : ImporterBase(logger)
{
public override string Format => "csv";
protected override ValueTask ParseRowAsync(string line, CancellationToken ct) =>
sink.AddAsync(OrderRow.ParseCsv(line), ct);
}
// Interface with static abstract members: something an abstract class cannot do
public interface IHasCode<TSelf> where TSelf : IHasCode<TSelf>
{
static abstract TSelf FromCode(string code);
}
What happens: abstract class Disposable or abstract class Comparable used across unrelated types.
Why it is wrong: consumes the single base-class slot and excludes structs.
Do instead: use interfaces for capabilities.
What happens: shared logic moved into default interface methods with awkward workarounds for state. Why it is wrong: no fields, confusing call rules, diamond issues. Do instead: use an abstract base class for shared state and implementation.
What happens: services take PaymentGatewayBase instead of IPaymentGateway.
Why it is wrong: consumers are coupled to implementation details; mocking is harder.
Do instead: consumers depend on the interface.
What happens: one interface with many unrelated members. Why it is wrong: implementers must provide everything; consumers depend on too much. Do instead: split into role interfaces.
What happens: new abstract interface or class members in a minor library release. Why it is wrong: breaks every implementer. Do instead: add defaults (DIM or virtual) or new interfaces.
What happens: subclasses directly manipulate base fields. Why it is wrong: base invariants can't be enforced. Do instead: protected constructors, private state, protected methods enforcing rules.
What happens: outdated answers in interviews. Why it is wrong: ignores C# 8 DIMs and C# 11 static abstract members. Do instead: explain modern capabilities and their limits.
What happens: teams always use base classes (or always interfaces). Why it is wrong: mismatched tools cause rigidity or duplication. Do instead: apply the state/flow/capability/versioning criteria.
Seniors frame this as who depends on the type and how it will evolve.
| Audience | Give them |
|---|---|
| Consumers and tests | A small interface |
| Implementers who want help | An abstract base implementing that interface |
| Generic algorithms | Interfaces with static abstract members |
| Framework extension point that will grow | Abstract class with virtual members |
| Value types | Interface |
They note that the BCL's own guidance changed over time: it historically preferred abstract classes for versioning, and default interface methods softened but did not remove that preference, because state still needs a class.
An interface is a contract: a set of members a type promises to provide, with no instance state. A class or struct can implement many interfaces. Since C# 8 an interface can carry default implementations (used only through the interface, and they need .NET Core 3.0+), plus static, private and protected members. Since C# 11 it can declare static abstract / static virtual members, which power generic math (INumber<T>, T.Zero). Explicit implementation (void IDisposable.Dispose()) hides a member unless you cast. Calls through an interface use interface dispatch, which the JIT can often devirtualise. Casting a struct to an interface boxes it; a generic constraint (where T : IComparable<T>) avoids the box.
An abstract class is a base class that cannot be instantiated and may declare abstract members with no body, which every non-abstract derived class must override. Unlike an interface it can hold instance fields, constructors and fully implemented members, so it is the place for shared state and a shared algorithm. The classic use is the template method pattern: a public non-virtual method defines the steps and calls protected abstract or protected virtual hooks. Constructors should be protected. A class can derive from only one abstract class. Adding a new virtual member with a body is non-breaking, but adding a new abstract member breaks every derived class.
Default to composition: build behaviour from small objects behind interfaces and inject them. It keeps coupling low, lets behaviour change at run time and is easy to test. Use inheritance only when all of these hold: there is a true is-a relationship where every subtype can substitute for the base (Liskov), the base was designed for extension (documented virtual hooks, protected constructor), there is one axis of variation, and you want to share state plus a fixed algorithm (template method). Framework extension points such as BackgroundService, DelegatingHandler and DbContext are good inheritance cases. When a hierarchy explodes or subclasses override things to throw NotSupportedException, refactor by extracting the varying behaviour into strategies and composing them.
How to tell it (STAR)
**Situation:** our import plug-ins each reimplemented streaming and cancellation, and several had bugs, including out-of-memory on large files. **Task:** remove the duplication without coupling the host to implementations. **Action:** I kept the interface for the host and added an abstract base with a template method that handles streaming, progress and cancellation. Plug-ins only parse rows. I added a contract test suite for every importer. **Result:** plug-ins shrank from about [400] to [60] lines, the memory and cancellation bugs disappeared, and two new formats shipped in a sprint.
Last updated 30 Oct 2026.