📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

Interface or abstract class: how do you choose in modern C#, now that interfaces can have default implementations and static abstract members? Give the criteria, real BCL examples of each, and when you would use both together.

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.

Senior Architecture Advanced abstract-class api-design csharp design interfaces oop versioning

🎯 Key points

  • Interfaces define capabilities; a type can implement many, including structs
  • Abstract classes share state, constructor invariants and fixed algorithms; a class has one base
  • Default interface methods help evolve published interfaces but cannot hold state
  • Static abstract members (C# 11) exist only in interfaces and enable generic math and factories
  • Adding a virtual member to an abstract class is non-breaking; adding a bodiless member to either is breaking
  • BCL uses abstract classes for growing extension points (Stream, DbConnection) and interfaces for capabilities (IDisposable)
  • A strong pattern is an interface for consumers plus an abstract base for implementers
  • Keep interfaces small and role-based (Interface Segregation)

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. Criteria

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

2. What changed with modern C#

  • Default interface methods (C# 8): interfaces can add members with bodies without breaking implementers — great for library evolution. But they have no state, are only callable via the interface type, and resolution rules in diamonds can surprise. They're not a replacement for base classes.
  • Static abstract members (C# 11): interfaces can require static members (factories, 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.
  • Neither change gives interfaces instance state or constructors, so the template-method and shared-state use cases still belong to abstract classes.

3. Real BCL examples

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).

4. When I use both

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.

5. A production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. Technical differences

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

2. Versioning in practice

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:

  • DIMs can't add state, so new features needing storage can't be added to an interface.
  • DIMs need runtime support (.NET Core 3.0+); libraries targeting netstandard2.0 can't use them.
  • Diamond conflicts across interfaces with defaults can cause 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.

3. Mocking and testing

  • Interfaces are trivially mockable (all members abstract or with defaults).
  • Abstract classes: only 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).
  • Providing both lets consumers mock the interface while implementers test against the base with small subclasses.

4. Performance considerations

  • Calls through an interface use virtual stub dispatch (fast when monomorphic); calls through an abstract class use vtables. Both can be devirtualised when the type is known.
  • For structs, interfaces in generic constraints give zero-cost abstraction (specialised code, no boxing) — something abstract classes can't offer at all.
  • Static abstract members enable generic algorithms over numeric types with no runtime dispatch.

5. Interface segregation and role interfaces

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.

6. Decision summary

  • Contract for consumers / DI / capabilities / structs / generic math → interface.
  • Shared state, constructor validation, fixed algorithm with hooks → abstract class.
  • Public library where both matter → interface + abstract base class.
  • Evolving a widely implemented public contract with state needs → abstract class.

Try it yourself

  1. Implement an interface + abstract base pair and write tests mocking the interface and subclassing the base.
  2. Add a default interface method and a virtual method to an abstract class in a library; recompile a consumer and compare.
  3. Implement IParsable<T> on a value object and write a generic ParseAll<T>.
  4. Create a diamond with two interfaces providing conflicting defaults.

💻 Code

// 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);
}

⚠ Common mistakes

1. Using abstract classes for capabilities

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.

2. Treating DIMs as stateful base classes

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.

3. Depending on the abstract class in consumers

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.

4. Fat interfaces

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.

5. Adding abstract members to published contracts

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.

6. Abstract classes with public constructors and mutable protected state

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.

7. Saying "interfaces can't have implementations"

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.

8. Choosing based on habit rather than criteria

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions default interface methods Why: Checks precise interface rules
    How to answer

    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.

  2. When: Candidate mentions shared state in a base class Why: Checks abstract class constructors and versioning
    How to answer

    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.

  3. When: Candidate discusses base classes for reuse Why: Moves to composition versus inheritance judgement
    How to answer

    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.

🛠 Real-world problems: how you handle them

Plug-in importers rebuilt with interface plus abstract base
Situation
An import platform had [nine] plug-ins implementing `IImporter` directly; each reimplemented streaming, progress and cancellation.
Signals
Three plug-ins ignored cancellation, two reported progress wrongly, and one loaded whole files into memory, causing out-of-memory errors on [2 GB] uploads.
Investigation
Compared plug-ins and found about [80%] duplicated plumbing; the bugs were all in that plumbing.
Resolution
Kept `IImporter` for the host, added `ImporterBase` with a protected constructor, a template `ImportAsync` that streams, reports progress and honours cancellation, and an abstract `ParseRowAsync`. Migrated plug-ins one by one.
Prevention
A contract test suite runs against every registered `IImporter` (cancellation, progress, large file) in CI.

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.

↩ Usually asked after

More in Design Judgment

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me