Abstract classes in C#: abstract versus virtual members, constructors and shared state, the template method pattern, BCL examples and versioning trade-offs.
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.
An abstract class is a class that can't be instantiated and may contain abstract members (no body) that derived classes must implement, alongside ordinary members with implementation and state. It's for a family of closely related types that share both a contract and real behaviour. I'd cover abstract members, constructors and state, the template method pattern, and how abstract classes evolve differently from interfaces.
public abstract class PaymentProcessor(ILogger logger, TimeProvider clock)
{
protected ILogger Logger { get; } = logger;
// Template method: fixed algorithm, not overridable
public async Task<PaymentResult> ProcessAsync(PaymentRequest request, CancellationToken ct)
{
Validate(request);
var started = clock.GetTimestamp();
var result = await ChargeAsync(request, ct); // varies per provider
Logger.LogInformation("Charged {Amount} via {Provider} in {Elapsed} ms",
request.Amount, ProviderName, clock.GetElapsedTime(started).TotalMilliseconds);
await OnChargedAsync(result, ct); // optional hook
return result;
}
protected abstract string ProviderName { get; }
protected abstract Task<PaymentResult> ChargeAsync(PaymentRequest request, CancellationToken ct);
protected virtual Task OnChargedAsync(PaymentResult result, CancellationToken ct) => Task.CompletedTask;
protected virtual void Validate(PaymentRequest request) =>
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(request.Amount.Value);
}
abstract members have no body and must be overridden by every concrete derived class (they're implicitly virtual).virtual members have a default and may be overridden ("hooks").ProcessAsync above.abstract.protected — called by derived classes via base(...). They initialise shared state and enforce invariants for the whole family.This is the key capability interfaces lack: interfaces can't have instance state or constructors.
The base class defines the skeleton of an algorithm (validation → charge → log → hook) and lets subclasses fill in steps. Benefits: cross-cutting steps (logging, timing, validation, error handling) can't be forgotten by subclasses, and the order is fixed in one place. .NET examples:
BackgroundService.ExecuteAsync — the base handles StartAsync/StopAsync and cancellation.Stream — many concrete methods built on a few abstract ones (Read, Write, Seek).DelegatingHandler.SendAsync, JsonConverter<T>.Read/Write, AuthenticationHandler<TOptions>.HandleAuthenticateAsync.IHostedService + BackgroundService, IComparer<T> + Comparer<T>.I avoid it for unrelated types needing a common capability (use an interface), for structs (can't inherit), and when variation is better composed (strategies).
| Change | Abstract class | Interface |
|---|---|---|
| Add a member with a default body | Non-breaking (new virtual method) | Non-breaking with a default interface method (C# 8+) |
| Add an abstract member | Breaking for derived classes | Breaking without a default |
| Add state/fields | Easy (private fields) | Impossible |
| Multiple inheritance | No | Yes |
| Works with structs | No | Yes |
Historically abstract classes were the safer choice for evolving public APIs ("prefer classes over interfaces" in the Framework Design Guidelines). Default interface methods narrowed the gap, but abstract classes still win when evolution needs state or protected helpers.
Four payment provider integrations each implemented IPaymentGateway from scratch; two forgot idempotency-key handling and one didn't log latency. We introduced an abstract PaymentGatewayBase implementing the interface, with a sealed template ChargeAsync handling idempotency, retries, metrics and logging, and an abstract SendChargeAsync for the provider call. New providers became ~50 lines, and the cross-cutting behaviour became impossible to forget. We kept the interface so the application layer and tests didn't depend on the base class.
Checks that the candidate can explain abstract classes in terms of design, not only syntax.
Green flags: abstract versus virtual members; constructors and state; protected constructors; template method with a non-virtual public method; BCL examples such as Stream or BackgroundService; versioning (abstract members break subclasses); knows when to move to composition.
Red flags: "an abstract class is an interface with some code"; cannot say why you would pick it over an interface; virtual template methods; deep hierarchies used for reuse.
abstract means to the compiler and runtimenew AbstractType() (CS0144). The type is marked abstract in metadata, and the runtime also refuses to instantiate it.virtual abstract with no IL body; they get vtable slots that derived classes fill. A concrete class with an unfilled slot can't load (TypeLoadException), which the compiler prevents.public abstract override string ToString();) to force further subclasses to implement it.A public constructor on an abstract class is pointless (nobody can call it directly) and analysers (CA1012) suggest protected. The constructor runs as part of the derived constructor chain, so it can validate shared parameters and establish invariants for every subclass.
Avoid calling abstract/virtual members from the base constructor (CA2214): the derived constructor body hasn't run yet.
To stop subclasses overriding the algorithm:
sealed override.Keep hooks protected (not public) so callers use the template, not the steps. Name hooks clearly (OnChargedAsync, ChargeCoreAsync) — the BCL uses the ...Core suffix convention (DisposeAsyncCore, ReadCore).
The template method can often be replaced by composition: PaymentProcessor taking an IProviderClient strategy. Trade-offs:
| Template method (inheritance) | Strategy (composition) |
|---|---|
| Steps share base state and helpers easily | Steps get data via parameters |
| One axis of variation | Multiple independent axes |
| Fixed at compile time | Swappable at run time |
| Subclass coupled to base internals | Coupled only to the strategy interface |
For one stable axis of variation with lots of shared behaviour, the template method is simple and clear. For multiple or changing axes, compose.
Abstract entity base classes (e.g. AuditableEntity with CreatedAt, UpdatedBy) are common. If the base isn't mapped as an entity, EF Core just maps inherited properties into each derived table. If you map the hierarchy (TPH/TPT/TPC), the abstract base becomes part of the inheritance mapping — decide deliberately; shared audit columns usually don't need a mapped hierarchy.
ToString() in a base class and see the compiler force subclasses to implement it.public abstract class Exporter
{
private readonly IAuditLog _audit;
protected Exporter(IAuditLog audit) =>
_audit = audit ?? throw new ArgumentNullException(nameof(audit));
// Template method: not virtual, so no subclass can skip a step
public async Task<ExportFile> ExportAsync(User user, Query q, CancellationToken ct)
{
if (!user.CanExport) throw new UnauthorizedAccessException();
var rows = await q.RunAsync(ct);
var bytes = Render(rows);
await _audit.RecordAsync(user.Id, GetType().Name, rows.Count, ct);
return new ExportFile(FileName(q), ContentType, bytes);
}
protected abstract string ContentType { get; }
protected abstract byte[] Render(IReadOnlyList<Row> rows);
protected virtual string FileName(Query q) => $"{q.Name}-{DateTime.UtcNow:yyyyMMdd}";
}
public sealed class CsvExporter(IAuditLog audit) : Exporter(audit)
{
protected override string ContentType => "text/csv";
protected override byte[] Render(IReadOnlyList<Row> rows) =>
Encoding.UTF8.GetBytes(string.Join('\n', rows.Select(r => r.ToCsv())));
}
// BCL example: BackgroundService is abstract
public sealed class OutboxWorker(IOutbox outbox) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
await outbox.DispatchPendingAsync(stoppingToken);
}
}
What happens: a BaseEntityService or BaseHelper used just to share utility methods.
Why it is wrong: creates false is-a relationships and couples unrelated classes.
Do instead: use composition or static helpers; reserve abstract classes for real families.
What happens: hook methods are public, so callers invoke steps directly and skip validation/logging.
Why it is wrong: breaks the guarantees the template provides.
Do instead: make hooks protected and the template the only public entry point.
What happens: the template method is virtual, and subclasses override it entirely.
Why it is wrong: subclasses bypass cross-cutting behaviour.
Do instead: keep the template non-virtual (or sealed override).
What happens: the base constructor calls ProviderName or another abstract member.
Why it is wrong: derived fields aren't initialised yet — NREs and wrong values.
Do instead: call abstract members lazily or in an explicit initialisation step.
What happens: a library adds a new abstract method in a minor version. Why it is wrong: every derived class breaks. Do instead: add virtual members with sensible defaults.
What happens: every step is virtual "for flexibility". Why it is wrong: subclasses can change behaviour in unpredictable combinations; the base contract becomes unclear. Do instead: expose a few deliberate hooks; keep the rest private.
What happens: consumers depend on the abstract class directly. Why it is wrong: tests must subclass or mock non-virtual members; consumers are coupled to implementation. Do instead: keep an interface for consumers and use the abstract class as an implementation helper.
What happens: public PaymentProcessor(...).
Why it is wrong: misleading; the class can't be instantiated directly (CA1012).
Do instead: make constructors protected.
Seniors use an abstract class when there is real shared state or a fixed algorithm, and switch to composition when variation has more than one axis.
| Need | Choose |
|---|---|
| Fixed workflow, steps vary by type | Abstract class with a template method |
| Shared invariants on constructor arguments | Abstract class with a protected constructor |
| A framework extension point that will grow | Abstract class (add virtual members later) |
| Many unrelated types share a capability | Interface |
| Several independent axes of variation | Composition with strategies |
| Consumers should not know about the base | Interface for consumers plus abstract base for implementers |
They also say why the BCL made Stream an abstract class: dozens of members were added over the years (ReadAsync, CopyToAsync, Read(Span<byte>)) as virtuals with default bodies, and no existing stream broke.
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.
A constructor runs when an object is created and should leave it valid. If you declare none, the compiler adds a public parameterless one. Constructors can chain: : this(...) calls another constructor of the same class, : base(...) calls the base class's. For new Derived(), the order is: derived field initialisers, base field initialisers, base constructor body, derived constructor body. A static constructor runs once, before the type is first used, and is thread-safe. Primary constructors (C# 12) put parameters on the class declaration; they are captured as hidden fields, not properties, and are mutable. required members (C# 11) force callers to set them in an object initialiser. Calling a virtual method from a constructor runs the derived override before the derived constructor body has run, so it can see uninitialised state.
Composition means a class has other objects as parts and delegates work to them, instead of inheriting behaviour. A Car has an Engine and Wheels; an OrderProcessor has a validator, a pricer and a notifier. In the strict UML sense, composition also means ownership: the parts are created and destroyed with the whole and are not shared. Benefits: behaviour can be swapped (pass a different IPricingStrategy), combined freely (decorators wrap each other), tested in isolation, and changed without a fragile base class. The cost is a little more wiring, usually handled by constructors or dependency injection.
How to tell it (STAR)
**Situation:** compliance found that [two of five] export formats wrote no audit record. **Task:** fix the gap and stop it coming back. **Action:** I moved the shared flow into an abstract `Exporter` with a non-virtual template method, so subclasses only render. I added a reflection-based test that checks every exporter writes an audit entry. **Result:** the audit gap closed, the export code shrank by about [60%], and a sixth format later took [half a day] instead of copying a whole flow.
Last updated 30 Oct 2026.