Abstract Classes vs Interfaces in C# — A Deep, Real‑World Comparison
A practical mental model for designing clean, testable, evolvable systems in C#.
This guide explains when to use an interface vs an abstract class, why the difference matters, and how senior engineers combine both patterns in production-grade architectures.
Abstract Class vs Interface: The Core Difference
What an interface is
An interface is a capability contract:
“If you implement this, you promise you can do these things.”
It’s about what you can do, not what you are.
What an abstract class is
An abstract class is a base type with shared identity + shared implementation:
“You are a kind of this thing, and you share some code and rules.”
It’s about is‑a + shared behavior.
Decision Rules You Can Actually Use
Prefer an interface when…
1) You want multiple behaviors
C# allows:
- 1 base class
- many interfaces
So interfaces are the natural tool for composable architecture.
Example
public interface ICacheable { string CacheKey { get; } }
public interface IAuditable { DateTimeOffset CreatedAt { get; } }
public interface IValidatable { void Validate(); }
public class Order : ICacheable, IAuditable, IValidatable
{
public string CacheKey => $"order:{Id}";
public DateTimeOffset CreatedAt { get; } = DateTimeOffset.UtcNow;
public Guid Id { get; } = Guid.NewGuid();
public void Validate() { /* ... */ }
}
2) You want loose coupling / DI‑friendly design
Most DI containers and Clean Architecture flows are interface‑first:
IEmailSenderIClockIRepository<T>
Interfaces are ideal at module boundaries because they reduce coupling and make substitution easy.
3) You’re designing public API boundaries
Interfaces are the cleanest way to expose contracts without locking consumers into your inheritance tree.
4) You need many implementations
Example: IPaymentGateway with:
- Stripe
- Adyen
- Mock
- Sandbox
- etc.
Interfaces scale cleanly when you expect many variants.
Prefer an abstract class when…
1) You need shared code + shared state
Interfaces can’t hold instance state (fields). Abstract classes can.
Example
public abstract class Handler<TRequest, TResponse>
{
protected readonly ILogger Logger;
protected Handler(ILogger logger) => Logger = logger;
public async Task<TResponse> HandleAsync(TRequest request, CancellationToken ct)
{
Logger.Info($"Handling {typeof(TRequest).Name}");
Validate(request);
return await ExecuteAsync(request, ct);
}
protected virtual void Validate(TRequest request) { }
protected abstract Task<TResponse> ExecuteAsync(TRequest request, CancellationToken ct);
}
This is the Template Method pattern: a shared workflow + customizable steps.
2) You want to enforce invariants
Abstract base types are great when the rule is:
“You must go through this pipeline.”
They ensure consumers don’t bypass required steps.
3) The types share a strong identity
Examples:
ShapeStreamDbContextControllerBase
These types are “kinds of” the base type. That identity matters.
The “Default Interface Methods” Nuance (C# 8+)
Interfaces can now include:
- default method bodies
- static members (in newer versions)
- but still no instance fields/state
So default interface methods help versioning and small shared behavior, but they don’t replace abstract classes for shared state + invariants.
Example (default behavior without state)
public interface IRetryable
{
int MaxRetries => 3; // default property
TimeSpan Delay => TimeSpan.FromMilliseconds(200);
async Task ExecuteWithRetryAsync(Func<Task> action)
{
for (var i = 1; i <= MaxRetries; i++)
{
try { await action(); return; }
catch when (i < MaxRetries) { await Task.Delay(Delay); }
}
}
}
This is useful, but if you need shared fields (like metrics, logger, counters), you’re back to abstract classes.
Versioning and Evolution
Interfaces: best for contracts, but be careful when changing them
- Adding a new member to an interface can break implementations (unless you provide a default implementation).
- Great for boundaries, but you must think about compatibility.
Abstract classes: easier to evolve internals
- You can add protected helpers, non-abstract members without breaking derived classes.
- But inheritance hierarchies can become rigid and fragile.
Testing Impact
Interface
Testing is easy: mock/fake the interface.
public class BillingService
{
private readonly IPaymentGateway _gateway;
public BillingService(IPaymentGateway gateway) => _gateway = gateway;
}
Abstract class
You usually test derived implementations, or use a “test subclass”.
Mocking abstract base types is possible, but the design tends to couple things.
Performance Notes (Practical, Not Micro‑Obsessed)
- Both are reference-type dispatch most of the time.
- Interface calls can sometimes be slightly harder to inline than sealed concrete calls.
- In real systems: choose based on design clarity, not tiny perf differences.
- If performance truly matters, you’ll likely use
sealed, structs, or specialized patterns anyway.
Common Anti‑Patterns to Avoid
❌ “God abstract base class”
A huge base class with tons of dependencies and unrelated helpers → makes everything rigid.
❌ “Marker interface soup”
Too many tiny interfaces with no behavior, used everywhere, causing type explosion.
❌ Inheritance to share code only
If the only reason is “I don’t want to duplicate code”, prefer composition.
The Best “Senior” Pattern: Use Both Together
A very common production approach:
- Interface = external contract (DI boundary)
- Abstract class = internal shared implementation for a family
Example
public interface INotificationSender
{
Task SendAsync(string to, string message, CancellationToken ct);
}
public abstract class NotificationSenderBase : INotificationSender
{
protected readonly ILogger Logger;
protected NotificationSenderBase(ILogger logger) => Logger = logger;
public Task SendAsync(string to, string message, CancellationToken ct)
{
Guard(to, message);
return SendCoreAsync(to, message, ct);
}
protected abstract Task SendCoreAsync(string to, string message, CancellationToken ct);
private static void Guard(string to, string message)
{
if (string.IsNullOrWhiteSpace(to)) throw new ArgumentException(nameof(to));
if (string.IsNullOrWhiteSpace(message)) throw new ArgumentException(nameof(message));
}
}
public sealed class EmailSender : NotificationSenderBase
{
public EmailSender(ILogger logger) : base(logger) { }
protected override Task SendCoreAsync(string to, string message, CancellationToken ct)
=> Task.CompletedTask; // real email here
}
This gives you:
- DI-friendly contract (
INotificationSender) - shared pipeline (
NotificationSenderBase) - sealed concrete for safety and clarity (
EmailSender)
Quick Chooser
Choose interface when:
- multiple implementations
- dependency injection boundary
- you want composition
- “can-do” capability
Choose abstract class when:
- shared state or shared pipeline
- strong “is-a” relationship
- enforcing invariants and workflow

Top comments (0)